tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The roadmap diagnostic vocabulary: one closed set of kinds, ordered and
//! field-aware (WP03, T015, C-005/C-006).
//!
//! A roadmap problem is a **value**, not a dropped row and never a silently-zero
//! figure. Just as the health mission ([`crate::health::HealthDiagnostic`]) made a
//! health finding a typed value every surface renders, a roadmap finding is a
//! [`RoadmapDiagnostic`]: a stable kind, the initiative it belongs to, the record
//! **field** it sits at, the reference that failed, the source entity that reported
//! it, and a remedy. The shape mirrors the two service diagnostic vocabularies this
//! mission consumes — `#178`'s project and `#179`'s epic findings — so a reader who
//! learned one reads this one.
//!
//! # Why the kinds are closed
//!
//! `missing_reference`, `empty_scope`, `unresolvable_rollup`, `source_diagnostic`,
//! `unknown_value`, `undated`. A new kind is a vocabulary decision, not a string
//! somebody typed, and a new metric would be the escalation the mission's risk list
//! names — so the set is an enum with a stable spelling
//! ([`RoadmapDiagnosticKind::as_str`]) a surface can match on.
//!
//! # Zero, unknown, empty and missing are distinct
//!
//! The mission is explicit that `0` is never the answer for "nobody said". This
//! module does not carry numbers at all — it carries the **reason** a value is
//! absent:
//!
//! * [`RoadmapDiagnosticKind::EmptyScope`] — an initiative that records nothing to
//!   contribute (C-006's explicit empty state);
//! * [`RoadmapDiagnosticKind::MissingReference`] — a recorded reference that does
//!   not resolve (C-005);
//! * [`RoadmapDiagnosticKind::UnknownValue`] — every reference resolves, but no
//!   source can say anything;
//! * [`RoadmapDiagnosticKind::UnresolvableRollup`] — a resolved source whose owner
//!   could not produce its value.
//!
//! A genuine zero-progress figure is a *value* on the contribution, never a
//! diagnostic — the two never stand in for one another.
//!
//! # Ordering
//!
//! Diagnostics are **sorted** by [`order`] before they reach a surface, so two
//! reads of one snapshot list them identically. The key is `(kind, initiative,
//! field, reference, source, message)`: the kind first because a reader triages by
//! severity of meaning, then the initiative and field because that is how a fix is
//! located.

use std::fmt;

/// The closed kind of one roadmap finding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RoadmapDiagnosticKind {
    /// A recorded association that does not resolve to a source entity (C-005).
    MissingReference,
    /// An initiative that records no reference at all: nothing can contribute
    /// (C-006's explicit empty state).
    EmptyScope,
    /// A resolved source whose owning service could not produce its value.
    UnresolvableRollup,
    /// A finding forwarded from a source service. The source's **message** (and the
    /// field/reference it names) is carried verbatim; the source service's own kind
    /// folds into this one, so it is not re-spelled here.
    SourceDiagnostic,
    /// Every reference resolves, but no resolved source reports a knowable value.
    UnknownValue,
    /// A row with no target date: the explicit ordering state, never an invented
    /// period (C-003).
    Undated,
}

impl RoadmapDiagnosticKind {
    /// The stable spelling every surface prints and matches on.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::MissingReference => "missing_reference",
            Self::EmptyScope => "empty_scope",
            Self::UnresolvableRollup => "unresolvable_rollup",
            Self::SourceDiagnostic => "source_diagnostic",
            Self::UnknownValue => "unknown_value",
            Self::Undated => "undated",
        }
    }

    /// Every kind, in a stable order — for a refusal or a parity test to quote.
    pub const ALL: [Self; 6] = [
        Self::MissingReference,
        Self::EmptyScope,
        Self::UnresolvableRollup,
        Self::SourceDiagnostic,
        Self::UnknownValue,
        Self::Undated,
    ];

    /// The remedy that fits this kind, when the caller has nothing more specific.
    ///
    /// A remedy is what makes a diagnostic actionable: "a reference is missing"
    /// says what is wrong, "declare the project, or remove the reference" says what
    /// to do. Kept here so every surface says the same thing (C-007).
    #[must_use]
    pub const fn default_remedy(self) -> Option<&'static str> {
        match self {
            Self::MissingReference => {
                Some("declare the referenced project/epic/milestone, or remove the reference")
            }
            Self::EmptyScope => Some(
                "associate a project, epic or milestone, or record a target for this initiative",
            ),
            Self::UnresolvableRollup => {
                Some("inspect the source service for the reference; the value could not be derived")
            }
            Self::SourceDiagnostic => {
                Some("resolve the source entity's own finding; it is reported verbatim here")
            }
            Self::UnknownValue => {
                Some("record progress or a health value on the referenced sources")
            }
            Self::Undated => Some("set a `target_date`, or leave the row in the undated state"),
        }
    }
}

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

/// One roadmap finding, with everything a surface needs to explain it (T015).
///
/// Every field is optional except the kind and the message, because a finding
/// names only what it knows: an initiative-wide empty state has no reference, a
/// forwarded source finding has no record field, and a missing reference has both.
/// The **field** is what makes the diagnostic field-aware — a surface can say
/// "`milestones`: `2026-Q4` does not resolve" rather than "something is missing".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoadmapDiagnostic {
    /// What is wrong.
    pub kind: RoadmapDiagnosticKind,
    /// The initiative (stable id) this finding belongs to, when it is row-local.
    pub initiative: Option<String>,
    /// The record field the problem sits at — `projects`, `epics`, `milestones`,
    /// or a source-side field name.
    pub field: Option<String>,
    /// The reference that could not be resolved, when the problem is a reference.
    pub reference: Option<String>,
    /// The source entity that reported the finding, spelled `<kind>:<id>`, when it
    /// is a forwarded source diagnostic.
    pub source: Option<String>,
    /// What is wrong, in one sentence.
    pub message: String,
    /// What the reader can do about it.
    pub remedy: Option<String>,
}

impl RoadmapDiagnostic {
    /// A finding with its message; the kind's default remedy is filled in.
    #[must_use]
    pub fn new(kind: RoadmapDiagnosticKind, message: impl Into<String>) -> Self {
        Self {
            kind,
            initiative: None,
            field: None,
            reference: None,
            source: None,
            message: message.into(),
            remedy: kind.default_remedy().map(str::to_string),
        }
    }

    /// Attach the initiative this finding belongs to.
    #[must_use]
    pub fn for_initiative(mut self, initiative: impl Into<String>) -> Self {
        self.initiative = Some(initiative.into());
        self
    }

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

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

    /// Name the source entity that reported the finding.
    #[must_use]
    pub fn about_source(mut self, source: impl Into<String>) -> Self {
        self.source = Some(source.into());
        self
    }

    /// Say what to do about it, overriding the kind's default.
    #[must_use]
    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
        self.remedy = Some(remedy.into());
        self
    }

    /// The deterministic ordering key.
    ///
    /// The kind first, then the initiative, then the field, then the reference, then
    /// the source, then the message — so no two distinct diagnostics compare equal
    /// and the order of a given set is total.
    fn order_key(&self) -> OrderKey<'_> {
        (
            self.kind.as_str(),
            self.initiative.as_deref(),
            self.field.as_deref(),
            self.reference.as_deref(),
            self.source.as_deref(),
            self.message.as_str(),
        )
    }
}

/// The tuple [`RoadmapDiagnostic::order_key`] compares, named so the type is not
/// spelled out at the call.
type OrderKey<'a> = (
    &'static str,
    Option<&'a str>,
    Option<&'a str>,
    Option<&'a str>,
    Option<&'a str>,
    &'a str,
);

/// Sort `diagnostics` in place into the canonical order.
///
/// Stable and total (see [`RoadmapDiagnostic::order_key`]), so two reads of one
/// snapshot produce byte-identical diagnostic lists — the determinism the contract
/// promises.
pub fn order(diagnostics: &mut [RoadmapDiagnostic]) {
    diagnostics.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
}

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

    #[test]
    fn every_kind_has_a_stable_spelling_and_a_remedy() {
        for kind in RoadmapDiagnosticKind::ALL {
            assert_ne!(kind.as_str(), "");
            assert!(kind.default_remedy().is_some(), "{kind} has no remedy");
        }
        // The spellings are distinct, so a surface can match on them.
        let mut spellings: Vec<&str> = RoadmapDiagnosticKind::ALL
            .iter()
            .map(|kind| kind.as_str())
            .collect();
        spellings.sort_unstable();
        spellings.dedup();
        assert_eq!(spellings.len(), RoadmapDiagnosticKind::ALL.len());
    }

    #[test]
    fn order_is_total_and_stable() {
        let mut diagnostics = vec![
            RoadmapDiagnostic::new(RoadmapDiagnosticKind::Undated, "b").for_initiative("i-a"),
            RoadmapDiagnostic::new(RoadmapDiagnosticKind::MissingReference, "z")
                .for_initiative("i-b")
                .at_field("projects")
                .referencing("p-x"),
            RoadmapDiagnostic::new(RoadmapDiagnosticKind::MissingReference, "z")
                .for_initiative("i-a")
                .at_field("epics")
                .referencing("e-x"),
        ];
        order(&mut diagnostics);
        assert_eq!(diagnostics[0].initiative.as_deref(), Some("i-a"));
        assert_eq!(diagnostics[0].field.as_deref(), Some("epics"));
        assert_eq!(diagnostics[1].initiative.as_deref(), Some("i-b"));
        assert_eq!(diagnostics[2].kind, RoadmapDiagnosticKind::Undated);
    }
}