tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The four health states (mission `project-issue-health-status-01M3C8HX`, WP01
//! T001).
//!
//! # A closed vocabulary
//!
//! `unknown`, `on_track`, `needs_attention`, `at_risk` — and nothing else. The
//! frontmatter value is parsed into [`HealthState`] and an unrecognised string is
//! refused with the value and the accepted set quoted back, rather than stored and
//! rendered as though a reader could interpret it. A state is a *decision about the
//! vocabulary*, so a new one is an escalation (C-003): a new policy version with
//! its own plan, not a string somebody typed.
//!
//! # Why `unknown` is a first-class state rather than an absence
//!
//! A legacy issue has no health history, and a parent whose children cannot be
//! resolved has nothing to derive from. Both are real, reportable states — "nobody
//! has said" — and both are *not* `on_track`. Modelling them as a missing value
//! would let every surface choose its own default, and the default would be
//! healthy: the one reading nobody intends and everybody gets (C-006).

use std::fmt;

/// One health state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HealthState {
    /// Nobody has said, or nothing can be derived: a legacy issue, an empty scope,
    /// or a scope whose contributors cannot be resolved.
    Unknown,
    /// Progressing as intended.
    OnTrack,
    /// Something needs a decision or a fix, but the outcome is not in doubt.
    NeedsAttention,
    /// The outcome is in doubt.
    AtRisk,
}

impl HealthState {
    /// The stable spelling written to files, printed by every surface, and matched
    /// by `tix health …` on the command line.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Unknown => "unknown",
            Self::OnTrack => "on_track",
            Self::NeedsAttention => "needs_attention",
            Self::AtRisk => "at_risk",
        }
    }

    /// The human label a TUI or a report shows.
    pub const fn label(self) -> &'static str {
        match self {
            Self::Unknown => "Unknown",
            Self::OnTrack => "On track",
            Self::NeedsAttention => "Needs attention",
            Self::AtRisk => "At risk",
        }
    }

    /// Every state, in the order a surface should offer them: the *documented*
    /// vocabulary, which is also what a refusal quotes back.
    pub const ALL: [Self; 4] = [
        Self::Unknown,
        Self::OnTrack,
        Self::NeedsAttention,
        Self::AtRisk,
    ];

    /// Parse a stored or typed state.
    ///
    /// Case- and separator-insensitive in the harmless direction only: `at_risk`,
    /// `at-risk`, `At Risk` and `atrisk` are the same word spelled by a hand, and
    /// the vocabulary is closed, so there is no state any of them could be
    /// confused with. `fine`, `green` and `done` are refused by name, because they
    /// are not states at all — and a value nobody can interpret is worse than a
    /// refusal that quotes the four that exist.
    pub fn parse(raw: &str) -> std::result::Result<Self, StateParseError> {
        let normalised: String = raw
            .trim()
            .chars()
            .filter(|c| !matches!(c, '-' | '_' | ' '))
            .map(|c| c.to_ascii_lowercase())
            .collect();
        match normalised.as_str() {
            "unknown" => Ok(Self::Unknown),
            "ontrack" => Ok(Self::OnTrack),
            "needsattention" => Ok(Self::NeedsAttention),
            "atrisk" => Ok(Self::AtRisk),
            _ => Err(StateParseError {
                raw: raw.to_string(),
            }),
        }
    }

    /// The **severity rank** of this state, where a larger number is worse.
    ///
    /// `unknown` is deliberately the lowest rank: it means "nobody has said", which
    /// is not a *worse* health than `on_track` — it is the absence of an answer, and
    /// the policy decides what that resolves to rather than this ordering. The three
    /// real states order `at_risk > needs_attention > on_track`, which is the
    /// precedence `health-v1` documents.
    pub const fn severity(self) -> u8 {
        match self {
            Self::Unknown => 0,
            Self::OnTrack => 1,
            Self::NeedsAttention => 2,
            Self::AtRisk => 3,
        }
    }
}

impl fmt::Display for HealthState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A state string that is not part of the vocabulary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateParseError {
    /// What was written.
    pub raw: String,
}

impl StateParseError {
    /// The one sentence a surface shows.
    pub fn render(&self) -> String {
        format!(
            "`{}` is not a health state; the states are: {}",
            self.raw,
            HealthState::ALL
                .iter()
                .map(|state| state.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        )
    }
}

impl fmt::Display for StateParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.render())
    }
}

impl std::error::Error for StateParseError {}

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

    #[test]
    fn health_state_spellings_round_trip_and_are_stable() {
        for state in HealthState::ALL {
            assert_eq!(HealthState::parse(state.as_str()).unwrap(), state);
            assert_eq!(state.to_string(), state.as_str());
            assert_ne!(state.label(), "");
        }
        assert_eq!(HealthState::Unknown.as_str(), "unknown");
        assert_eq!(HealthState::OnTrack.as_str(), "on_track");
        assert_eq!(HealthState::NeedsAttention.as_str(), "needs_attention");
        assert_eq!(HealthState::AtRisk.as_str(), "at_risk");
    }

    #[test]
    fn health_state_parse_accepts_how_a_human_spells_them() {
        for raw in [
            "at_risk",
            "at-risk",
            "at risk",
            "At Risk",
            "AT_RISK",
            " at_risk ",
        ] {
            assert_eq!(
                HealthState::parse(raw).expect("a spelling of at_risk"),
                HealthState::AtRisk,
                "{raw:?}"
            );
        }
        for raw in ["OnTrack", "on track", "on-track", "ontrack", "ONTRACK"] {
            assert_eq!(
                HealthState::parse(raw).expect("a spelling of on_track"),
                HealthState::OnTrack,
                "{raw:?}"
            );
        }
    }

    #[test]
    fn health_state_parse_refuses_anything_else_by_name() {
        for raw in ["fine", "healthy", "green", "", "done", "at_riskish"] {
            let error = HealthState::parse(raw).expect_err("not a state: {raw}");
            assert_eq!(error.raw, raw);
            let rendered = error.render();
            assert!(
                rendered.contains("on_track") && rendered.contains("at_risk"),
                "{rendered}"
            );
            assert!(
                rendered.contains(&format!("`{raw}`")) || raw.is_empty(),
                "{rendered}"
            );
        }
    }

    #[test]
    fn health_state_severity_orders_the_three_real_states() {
        assert!(HealthState::AtRisk.severity() > HealthState::NeedsAttention.severity());
        assert!(HealthState::NeedsAttention.severity() > HealthState::OnTrack.severity());
        assert!(
            HealthState::Unknown.severity() < HealthState::OnTrack.severity(),
            "`unknown` is the absence of an answer, not a worse health — the policy says what it resolves to"
        );
    }
}