tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The shared analytics findings (mission `burndown-scope-analytics-01M3C9J1`,
//! WP02 T010).
//!
//! # One vocabulary, four surfaces
//!
//! `data-model.md` §7 fixes the closed six-name vocabulary — `missing_history`,
//! `unclassified_event`, `missing_conversion`, `invalid_period`,
//! `closed_snapshot`, `projection_unknown` — and this module is its one home. CLI,
//! TUI, MCP and the local chart document all render the **same value** through
//! [`AnalyticsDiagnostic::render`], so no surface can invent a seventh name or
//! re-spell an existing one (C-008); a surface that needs to branch reads
//! [`AnalyticsDiagnosticKind`], never the sentence beside it.
//!
//! # The reason/remedy shape
//!
//! The shape is the crate's #1643 one, the same pair
//! [`crate::capacity::warning::CapacityWarning`] and
//! [`crate::health::diagnostics::HealthDiagnostic`] carry: a machine-readable
//! [`kind`](AnalyticsDiagnosticKind), the stable scope id the finding is about, an
//! optional `date` and `event_id` locating it, a `message` that says what happened,
//! and an optional `remedy` table entry from
//! [`AnalyticsDiagnosticKind::default_remedy`] that says what to do about it. The
//! remedy table lives on the kind, not at each construction site, so two call
//! sites cannot explain one condition two ways.
//!
//! # Nothing here invents a value
//!
//! A diagnostic never carries a number the series did not derive. It names what is
//! missing (`missing_history`), what could not be placed (`unclassified_event`),
//! what the period excluded (`closed_snapshot`) or why a series could not be
//! enumerated at all (`invalid_period`) — and it is the *only* thing the fold adds
//! when it cannot answer: the point beside it stays `unknown`/`coverage_limited`
//! rather than becoming a zero (C-005).
//!
//! # Ordering
//!
//! Diagnostics are `Ord` over `(kind, scope, date, event_id, message, remedy)`, and
//! [`crate::analytics::series`] sorts them before a series leaves the service. Two
//! runs over one snapshot therefore produce byte-identical diagnostics, which is
//! C-007's determinism applied to the diagnostic half of the payload rather than
//! only to the points.

use std::fmt;

use crate::diagnostics::Diagnostic;

/// What one analytics finding is about (`data-model.md` §7).
///
/// The set is closed at the contract's six names. Three of them are produced by the
/// count-mode fold ([`MissingHistory`](Self::MissingHistory),
/// [`UnclassifiedEvent`](Self::UnclassifiedEvent),
/// [`InvalidPeriod`](Self::InvalidPeriod) and
/// [`ClosedSnapshot`](Self::ClosedSnapshot)); the other two are declared here so
/// WP03 fills its half of one vocabulary rather than starting a second
/// ([`MissingConversion`](Self::MissingConversion),
/// [`ProjectionUnknown`](Self::ProjectionUnknown)).
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticsDiagnosticKind {
    /// The period holds a day (or an event) with no recorded history, so a value
    /// beside it is coverage-limited rather than a zero (C-005).
    MissingHistory,
    /// A recorded event carries a class the fold cannot place, so it moves no
    /// line and is reported instead of dropped.
    UnclassifiedEvent,
    /// A value needs an explicit unit conversion the shared service does not
    /// supply. WP03 owns the resolution path; the name is fixed here (C-003).
    MissingConversion,
    /// A period could not be enumerated: a bound is absent with nothing observed
    /// to stand in for it, or the span is too long to be a series.
    InvalidPeriod,
    /// The scope is bound to a closed snapshot: its period and provenance are the
    /// snapshot's, and events after its close are not contributions (C-006).
    ClosedSnapshot,
    /// A projection could not be drawn from the recorded history. WP03 owns the
    /// forecast; the name is fixed here (C-004).
    ProjectionUnknown,
}

impl AnalyticsDiagnosticKind {
    /// Every kind, in the vocabulary's own order.
    pub const ALL: [AnalyticsDiagnosticKind; 6] = [
        AnalyticsDiagnosticKind::MissingHistory,
        AnalyticsDiagnosticKind::UnclassifiedEvent,
        AnalyticsDiagnosticKind::MissingConversion,
        AnalyticsDiagnosticKind::InvalidPeriod,
        AnalyticsDiagnosticKind::ClosedSnapshot,
        AnalyticsDiagnosticKind::ProjectionUnknown,
    ];

    /// The stable spelling every surface prints and every payload carries.
    ///
    /// These are the contract's own names (`data-model.md` §7), so a payload
    /// parser in another language matches on them without a translation table.
    pub const fn as_str(self) -> &'static str {
        match self {
            AnalyticsDiagnosticKind::MissingHistory => "missing_history",
            AnalyticsDiagnosticKind::UnclassifiedEvent => "unclassified_event",
            AnalyticsDiagnosticKind::MissingConversion => "missing_conversion",
            AnalyticsDiagnosticKind::InvalidPeriod => "invalid_period",
            AnalyticsDiagnosticKind::ClosedSnapshot => "closed_snapshot",
            AnalyticsDiagnosticKind::ProjectionUnknown => "projection_unknown",
        }
    }

    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
    pub fn parse(raw: &str) -> Option<Self> {
        Self::ALL
            .into_iter()
            .find(|kind| kind.as_str() == raw.trim())
    }

    /// What the reader can do about it — the remedy half of the #1643 shape.
    ///
    /// A kind this build has no remedy for returns `None`, and the diagnostic then
    /// carries only its reason. The table is *on the kind* so every construction
    /// site offers the same advice.
    pub const fn default_remedy(self) -> Option<&'static str> {
        match self {
            AnalyticsDiagnosticKind::MissingHistory => Some(
                "widen the period with an earlier `--from` so the recorded events that predate \
                 it are included, or read the coverage marker beside the value",
            ),
            AnalyticsDiagnosticKind::UnclassifiedEvent => None,
            AnalyticsDiagnosticKind::MissingConversion => Some(
                "choose `--mode count`, or declare the unit under `[kanban.<board>.wip.units]` \
                 and state the estimate in it",
            ),
            AnalyticsDiagnosticKind::InvalidPeriod => {
                Some("pass a bounded period, or record at least one dated event to bound it")
            }
            AnalyticsDiagnosticKind::ClosedSnapshot => None,
            AnalyticsDiagnosticKind::ProjectionUnknown => Some(
                "record at least one completion inside the period before asking for a forecast",
            ),
        }
    }
}

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

/// One analytics finding, with everything a surface needs to explain it
/// (`data-model.md` §7).
///
/// `scope` is the **stable** scope id, not the display string: the finding has to
/// survive the scope's `Display` changing, and a payload consumer keys on the id.
/// `date` and `event_id` are `Option` because a finding is not always about one
/// day or one event — an invalid period is about the whole series.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct AnalyticsDiagnostic {
    /// What is wrong.
    pub kind: AnalyticsDiagnosticKind,
    /// The stable id of the scope the finding is about.
    pub scope: String,
    /// The day the finding is about, when it is about one.
    pub date: Option<String>,
    /// The stable event id the finding is about, when it is about one.
    pub event_id: Option<String>,
    /// What is wrong, in one sentence — the reason half of the #1643 shape.
    pub message: String,
    /// What the reader can do about it — the remedy half of the #1643 shape.
    pub remedy: Option<String>,
}

impl AnalyticsDiagnostic {
    /// A finding with its reason; the remedy comes from
    /// [`AnalyticsDiagnosticKind::default_remedy`].
    pub fn new(
        kind: AnalyticsDiagnosticKind,
        scope: impl Into<String>,
        message: impl Into<String>,
    ) -> Self {
        Self {
            kind,
            scope: scope.into(),
            date: None,
            event_id: None,
            message: message.into(),
            remedy: kind.default_remedy().map(str::to_string),
        }
    }

    /// Place the finding on a day.
    pub fn on(mut self, date: impl Into<String>) -> Self {
        self.date = Some(date.into());
        self
    }

    /// Name the recorded event the finding is about.
    pub fn about_event(mut self, event_id: impl Into<String>) -> Self {
        self.event_id = Some(event_id.into());
        self
    }

    /// Replace the kind's default remedy with one this finding can be more exact
    /// about.
    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-analytics", self.message.clone())
            .with_note(format!("{}: {}", self.kind.as_str(), self.scope));
        if let Some(date) = self.date.as_deref() {
            diagnostic = diagnostic.with_note(format!("date: {date}"));
        }
        if let Some(event_id) = self.event_id.as_deref() {
            diagnostic = diagnostic.with_note(format!("event: {event_id}"));
        }
        match self.remedy.as_deref() {
            Some(remedy) => diagnostic.with_help(remedy).render(),
            None => diagnostic.render(),
        }
    }
}

impl fmt::Display for AnalyticsDiagnostic {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.kind, self.message)
    }
}