tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The health diagnostic vocabulary: one closed set of kinds, with the context a
//! surface needs to act on them (mission `project-issue-health-status-01M3C8HX`,
//! WP04 T019).
//!
//! # One vocabulary, four surfaces
//!
//! The data model fixes nine stable names — `unknown_health`, `missing_child`,
//! `missing_project`, `missing_epic`, `missing_cycle`, `missing_milestone`,
//! `invalid_state`, `policy_mismatch`, `unsupported_remote_field` — and requires the
//! **same kind and context** in the CLI, the TUI, MCP, and the portable and remote
//! output (C-007). That is why this module exists instead of each surface inventing
//! a message: a health problem is a [`HealthDiagnostic`] value, constructed once by
//! the index or the mapping, and every surface renders *that* value.
//!
//! # The context is the point
//!
//! A diagnostic that says "something is wrong" is a shrug. Each one therefore
//! carries the scope it is about, the reference or remote field that caused it, the
//! raw state when there is one, and a remedy — the sentence that tells the reader
//! what to do. Rendering goes through the crate's one renderer
//! ([`crate::diagnostics::Diagnostic`]), so the same value prints identically
//! everywhere and is testable without a terminal.
//!
//! # Severity, deliberately
//!
//! Every health diagnostic is a **warning**: health is metadata, and a missing
//! cycle reference means a rollup cannot count something — not that the repository
//! is broken (C-006). Nothing here fails a command or blocks a move.

use crate::diagnostics::Diagnostic;

use super::events::HealthScope;

/// A remote target health can be mapped to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RemoteTarget {
    /// The local portable DTO — carries everything, losslessly.
    Portable,
    /// GitLab issues.
    GitLab,
    /// Linear issues.
    Linear,
}

impl RemoteTarget {
    /// The stable spelling written to the portable document and printed by surfaces.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Portable => "portable",
            Self::GitLab => "gitlab",
            Self::Linear => "linear",
        }
    }

    /// Every target, in a stable order.
    pub const ALL: [Self; 3] = [Self::Portable, Self::GitLab, Self::Linear];
}

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

/// The stable contract names for health diagnostics (the data-model's closed set).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum HealthDiagnosticKind {
    /// A contributor whose health nobody has recorded, or which cannot be read.
    UnknownHealth,
    /// A parent or child reference that resolves to nothing.
    MissingChild,
    /// A member naming a project the repository does not declare.
    MissingProject,
    /// A member naming an epic the repository does not declare.
    MissingEpic,
    /// A member naming a cycle the repository does not declare.
    MissingCycle,
    /// A member naming a milestone the repository does not declare.
    MissingMilestone,
    /// A stored state this build cannot read.
    InvalidState,
    /// A stored derivation whose policy version this build does not implement.
    PolicyMismatch,
    /// A health value a remote target cannot carry.
    UnsupportedRemoteField,
}

impl HealthDiagnosticKind {
    /// The stable spelling every surface prints and matches on.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::UnknownHealth => "unknown_health",
            Self::MissingChild => "missing_child",
            Self::MissingProject => "missing_project",
            Self::MissingEpic => "missing_epic",
            Self::MissingCycle => "missing_cycle",
            Self::MissingMilestone => "missing_milestone",
            Self::InvalidState => "invalid_state",
            Self::PolicyMismatch => "policy_mismatch",
            Self::UnsupportedRemoteField => "unsupported_remote_field",
        }
    }

    /// Every kind, in a stable order — for a refusal or a parity test to quote.
    pub const ALL: [Self; 9] = [
        Self::UnknownHealth,
        Self::MissingChild,
        Self::MissingProject,
        Self::MissingEpic,
        Self::MissingCycle,
        Self::MissingMilestone,
        Self::InvalidState,
        Self::PolicyMismatch,
        Self::UnsupportedRemoteField,
    ];

    /// The remedy that fits this kind, when the caller has nothing more specific.
    ///
    /// A remedy is what makes a diagnostic actionable: "a cycle is missing" tells a
    /// reader what is wrong, "declare the cycle, or remove `cycles:` from the issue"
    /// tells them what to do. The table lives here so all four surfaces say the same
    /// thing (C-007).
    pub const fn default_remedy(self) -> Option<&'static str> {
        match self {
            Self::UnknownHealth => Some(UNKNOWN_HEALTH_REMEDY),
            Self::MissingChild => {
                Some("remove the `parent:` reference, or create the parent issue")
            }
            Self::MissingProject => Some("declare the project, or remove the project membership"),
            Self::MissingEpic => Some("declare the epic, or remove the epic membership"),
            Self::MissingCycle => Some("declare the cycle, or remove `cycles:` from the issue"),
            Self::MissingMilestone => {
                Some("declare the milestone, or remove `milestones:` from the issue")
            }
            Self::InvalidState => Some(INVALID_STATE_REMEDY),
            Self::PolicyMismatch => {
                Some("re-derive the value under a policy version this build implements")
            }
            Self::UnsupportedRemoteField => {
                Some("health stays local; a remote that lacks the field never clears it")
            }
        }
    }

    /// The kind for a scope that cannot be resolved.
    pub const fn for_scope(scope: &HealthScope) -> Self {
        match scope {
            HealthScope::Issue(_) => Self::MissingChild,
            HealthScope::Project(_) => Self::MissingProject,
            HealthScope::Epic(_) => Self::MissingEpic,
            HealthScope::Cycle(_) => Self::MissingCycle,
            HealthScope::Milestone(_) => Self::MissingMilestone,
        }
    }
}

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

/// One health problem, with everything a surface needs to explain it (T019).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthDiagnostic {
    /// What is wrong.
    pub kind: HealthDiagnosticKind,
    /// The scope it is about. Always a stable id (C-002).
    pub scope: HealthScope,
    /// The reference that could not be resolved, when the problem is a reference.
    pub reference: Option<String>,
    /// The field the problem sits at — a frontmatter key or a remote field name.
    pub field: Option<String>,
    /// The remote target, when the problem is about a mapping.
    pub target: Option<RemoteTarget>,
    /// The raw stored state, when the file records one this build cannot read.
    pub state: Option<String>,
    /// What is wrong, in one sentence.
    pub message: String,
    /// What the reader can do about it.
    pub remedy: Option<String>,
}

impl HealthDiagnostic {
    /// A diagnostic with its message; the context is added by the builder methods.
    pub fn new(kind: HealthDiagnosticKind, scope: HealthScope, message: impl Into<String>) -> Self {
        Self {
            kind,
            scope,
            reference: None,
            field: None,
            target: None,
            state: None,
            message: message.into(),
            remedy: kind.default_remedy().map(str::to_string),
        }
    }

    /// Name the reference that could not be resolved.
    pub fn referencing(mut self, reference: impl Into<String>) -> Self {
        self.reference = Some(reference.into());
        self
    }

    /// Name the field the problem sits at.
    pub fn at_field(mut self, field: impl Into<String>) -> Self {
        self.field = Some(field.into());
        self
    }

    /// Name the remote target involved.
    pub fn for_target(mut self, target: RemoteTarget) -> Self {
        self.target = Some(target);
        self
    }

    /// Record the raw state the file carries.
    pub fn carrying(mut self, state: impl Into<String>) -> Self {
        self.state = Some(state.into());
        self
    }

    /// Say what to do about it.
    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
        self.remedy = Some(remedy.into());
        self
    }

    /// Render through the crate's one renderer — identical bytes on every surface.
    pub fn render(&self) -> String {
        let mut diagnostic = Diagnostic::warning("E-tix-health", self.message.clone())
            .with_note(format!("{}: {}", self.kind.as_str(), self.scope));
        if let Some(reference) = &self.reference {
            diagnostic = diagnostic.with_note(format!("reference: {reference}"));
        }
        if let Some(field) = &self.field {
            diagnostic = diagnostic.with_note(format!("field: {field}"));
        }
        if let Some(target) = self.target {
            diagnostic = diagnostic.with_note(format!("target: {target}"));
        }
        if let Some(state) = &self.state {
            diagnostic = diagnostic.with_note(format!("recorded: {state}"));
        }
        if let Some(remedy) = &self.remedy {
            diagnostic = diagnostic.with_help(remedy.clone());
        }
        diagnostic.render()
    }

    /// Whether two diagnostics describe the same problem.
    ///
    /// Used to make a merge idempotent: a diagnostic a previous round trip already
    /// stored is *recognized* rather than appended a second time, which is what
    /// makes `document → JSON → document → JSON` byte-stable (T021).
    pub fn same_as(&self, other: &Self) -> bool {
        self.kind == other.kind
            && self.scope == other.scope
            && self.reference == other.reference
            && self.field == other.field
            && self.target == other.target
            && self.state == other.state
            && self.message == other.message
            && self.remedy == other.remedy
    }

    /// The one line the portable document carries, so a round trip can be checked
    /// without comparing structs the reader cannot see.
    pub fn describe(&self) -> String {
        format!("{} {} {}", self.kind.as_str(), self.scope, self.message)
    }
}

/// The remedy for a state the file records but this build cannot read.
pub const INVALID_STATE_REMEDY: &str =
    "set `health.state` to one of unknown, on_track, needs_attention, at_risk";

/// The remedy for a contributor that records no health at all.
pub const UNKNOWN_HEALTH_REMEDY: &str =
    "record `health.state` for the issue, or accept that this scope's health is unknown";

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

    #[test]
    fn health_diagnostic_kinds_are_the_data_models_closed_set() {
        let names: Vec<&str> = HealthDiagnosticKind::ALL
            .iter()
            .map(|k| k.as_str())
            .collect();
        assert_eq!(
            names,
            vec![
                "unknown_health",
                "missing_child",
                "missing_project",
                "missing_epic",
                "missing_cycle",
                "missing_milestone",
                "invalid_state",
                "policy_mismatch",
                "unsupported_remote_field",
            ]
        );
        assert_eq!(
            HealthDiagnosticKind::for_scope(&HealthScope::Issue(1)).as_str(),
            "missing_child"
        );
        assert_eq!(
            HealthDiagnosticKind::for_scope(&HealthScope::Milestone("m".into())).as_str(),
            "missing_milestone"
        );
        assert_eq!(RemoteTarget::ALL[2].as_str(), "linear");
    }

    #[test]
    fn health_diagnostic_renders_its_context_and_is_idempotent_by_value() {
        let diagnostic = HealthDiagnostic::new(
            HealthDiagnosticKind::UnsupportedRemoteField,
            HealthScope::Issue(7),
            "gitlab cannot carry a health state",
        )
        .referencing("issue:7")
        .at_field("health")
        .for_target(RemoteTarget::GitLab)
        .carrying("at_risk")
        .with_remedy("health stays local");
        let rendered = diagnostic.render();
        assert!(rendered.contains("unsupported_remote_field"), "{rendered}");
        assert!(rendered.contains("issue:7"), "{rendered}");
        assert!(rendered.contains("field: health"), "{rendered}");
        assert!(rendered.contains("target: gitlab"), "{rendered}");
        assert!(rendered.contains("recorded: at_risk"), "{rendered}");
        assert!(rendered.contains("health stays local"), "{rendered}");
        assert!(diagnostic.same_as(&diagnostic.clone()));
        assert!(
            diagnostic
                .describe()
                .starts_with("unsupported_remote_field issue:7")
        );
        assert!(!diagnostic.same_as(&HealthDiagnostic::new(
            HealthDiagnosticKind::InvalidState,
            HealthScope::Issue(7),
            "gitlab cannot carry a health state"
        )));
    }
}