tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The shared time findings: what a total reports when something is wrong
//! (mission `per-issue-time-tracking-01M3C9A4`, WP03 T016/T017).
//!
//! # One vocabulary, declared once
//!
//! [`TimeDiagnosticKind`] is the machine-readable half of every time finding, and
//! `data-model.md` §5 declares six of its names — `invalid_duration`, `invalid_date`,
//! `missing_author`, `malformed_entry`, `unsupported_remote_field`, `write_failed`.
//! The first four are the entry problems WP01's
//! [`TimeEntryError`] already carries a field and a kind for, and
//! [`TimeDiagnostic::from_entry_problem`] maps each variant onto them **exhaustively**,
//! so a variant added to the model cannot be silently read as something else. The
//! last two belong to later seams (WP07's remote mapping, WP02's write path) and are
//! declared here only so those packages consume one vocabulary instead of each
//! spelling their own — the remedy table below already answers both.
//!
//! Three more names are this package's, and they exist because data-model §3 requires
//! `invalid dates/identity/period bounds` to be *diagnostics before aggregation*:
//! `invalid_period` and `invalid_assignee` carry the filter's own refusals
//! ([`TimeFilterError`]), and `unknown_issue` says the scope named an issue the
//! snapshot does not hold — which is not the same as an issue that holds no entry,
//! and must not be read as one. §5's list is the entry/mapping vocabulary; widening
//! it once, here, is what keeps a surface from branching on two vocabularies.
//!
//! # The finding shape: a reason and a remedy
//!
//! Every finding says what is wrong ([`reason`](TimeDiagnostic::reason)) and what to
//! do about it ([`remedy`](TimeDiagnostic::remedy)) — the shape
//! [`crate::capacity::CapacityWarning`] and
//! [`crate::health::diagnostics::HealthDiagnostic`] use. The remedy is **never**
//! absent: [`TimeDiagnosticKind::default_remedy`] holds the table once and
//! [`TimeDiagnostic::new`] fills it in, so a reader always has a next step (C-006).
//!
//! # Findings never mutate
//!
//! A finding is a value: it names a kind, an issue, an entry, a field, a reason and a
//! remedy, and does nothing else. This module holds no reference to a store, a
//! snapshot or a config, so there is no seam through which a finding could become a
//! mutation (C-006).

use crate::diagnostics::Diagnostic;
use crate::time_tracking::entry::TimeEntryError;
use crate::time_tracking::filters::TimeFilterError;

/// What one shared time finding is about (data-model §5, widened once).
///
/// The kind is the machine-readable half, so a surface can branch on the condition
/// while the sentence beside it stays a sentence and the remedy table stays one
/// table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TimeDiagnosticKind {
    /// The duration is absent, or present and negative.
    InvalidDuration,
    /// The date is present and is not a `YYYY-MM-DD` calendar date — on an entry, or
    /// on the period bound a filter was handed.
    InvalidDate,
    /// The entry has no author, or only whitespace.
    MissingAuthor,
    /// A malformed *entry* rather than a malformed field value: no stable id, a note
    /// that cannot be one `History` line, or a preserved key shadowing a typed field.
    MalformedEntry,
    /// The filter's period is not a period: its start is later than its end.
    InvalidPeriod,
    /// The filter's assignee criterion names no identity.
    InvalidAssignee,
    /// The scope named an issue the snapshot does not hold, so its absence is about
    /// the scope rather than about recorded effort.
    UnknownIssue,
    /// A remote target cannot represent time data; raised by WP07's mapping (C-007).
    UnsupportedRemoteField,
    /// A write of current entries or History failed; raised by WP02's service.
    WriteFailed,
}

impl TimeDiagnosticKind {
    /// The stable spelling every surface prints and every payload carries.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::InvalidDuration => "invalid_duration",
            Self::InvalidDate => "invalid_date",
            Self::MissingAuthor => "missing_author",
            Self::MalformedEntry => "malformed_entry",
            Self::InvalidPeriod => "invalid_period",
            Self::InvalidAssignee => "invalid_assignee",
            Self::UnknownIssue => "unknown_issue",
            Self::UnsupportedRemoteField => "unsupported_remote_field",
            Self::WriteFailed => "write_failed",
        }
    }

    /// What the reader can do about it — the remedy half of the finding shape.
    ///
    /// The table lives here so every surface says the same thing (C-006): "duration
    /// `-5` is negative" names what happened, "write a non-negative number of whole
    /// minutes" tells the reader what to do about it. There is no `None` arm — every
    /// condition this vocabulary names has a next step.
    pub const fn default_remedy(self) -> &'static str {
        match self {
            Self::InvalidDuration => {
                "write `duration_minutes` as a non-negative whole number of minutes, or remove \
                 the entry"
            }
            Self::InvalidDate => {
                "write the date as `YYYY-MM-DD` (year >= 1970), or remove it: an optional date \
                 that cannot be read is not the same as no date"
            }
            Self::MissingAuthor => {
                "name who did the work: `author:` takes a handle (`ada`, `@ada`) or a full \
                 `Name <email>` identity"
            }
            Self::MalformedEntry => {
                "give the entry a stable `id:` that is never reused, keep `note:` to one line, and \
                 rename any preserved key that would shadow a typed field"
            }
            Self::InvalidPeriod => {
                "swap the bounds: the period is inclusive and its start must not be later than its \
                 end"
            }
            Self::InvalidAssignee => {
                "name an identity (`ada`, `@ada`, or `Ada Lovelace <ada@example.com>`), or drop \
                 the criterion"
            }
            Self::UnknownIssue => {
                "read that issue into the scope, or drop the selector: an issue the snapshot does \
                 not hold is not an issue that recorded nothing"
            }
            Self::UnsupportedRemoteField => {
                "keep the time data local: the remote target is left untouched and the local \
                 entries are unchanged"
            }
            Self::WriteFailed => {
                "fix the reported store error and retry; nothing was written, so the current \
                 entries and History are unchanged"
            }
        }
    }
}

/// One shared time finding: what it is, what it is about, and what to do
/// (data-model §5).
///
/// `issue` and `entry_id` are `Option` because a finding is not always about an
/// entry: a filter refusal has no entry at all. The pairing is the type's own
/// invariant — **a finding with no `entry_id` is about the scope, and one with an
/// `entry_id` is about that entry** — which is what
/// [`is_scope_finding`](Self::is_scope_finding) answers and what a total's explicit
/// state is derived from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeDiagnostic {
    /// What is wrong.
    pub kind: TimeDiagnosticKind,
    /// The issue the finding is about, when it is about one.
    pub issue: Option<u32>,
    /// The entry the finding is about, when it is about one. `None` means the finding
    /// is about the scope, not about an entry.
    pub entry_id: Option<String>,
    /// The field the problem belongs to, when it belongs to one.
    pub field: Option<&'static str>,
    /// What is wrong, in one sentence — the reason half of the finding shape.
    pub reason: String,
    /// What the reader can do about it — the remedy half of the finding shape. Never
    /// empty: [`Self::new`] fills in the kind's default and a caller may specialize
    /// it.
    pub remedy: String,
}

impl TimeDiagnostic {
    /// A finding about the scope, with the kind's default remedy.
    pub fn new(kind: TimeDiagnosticKind, reason: impl Into<String>) -> Self {
        Self {
            kind,
            issue: None,
            entry_id: None,
            field: None,
            reason: reason.into(),
            remedy: kind.default_remedy().to_string(),
        }
    }

    /// One entry field problem, mapped onto the shared vocabulary (T016).
    ///
    /// The reason is WP01's own sentence, which quotes the offending value, and the
    /// field is WP01's own field, so a bad duration reads the same whether it was read
    /// from a file or supplied to a mutation. The `match` is exhaustive over
    /// [`TimeEntryError`] rather than reading its `kind()` string, because a variant
    /// added to the model must force a decision here instead of falling into a
    /// catch-all.
    pub fn from_entry_problem(issue: u32, entry_id: &str, problem: &TimeEntryError) -> Self {
        let kind = match problem {
            TimeEntryError::InvalidDuration { .. } => TimeDiagnosticKind::InvalidDuration,
            TimeEntryError::InvalidDate { .. } => TimeDiagnosticKind::InvalidDate,
            TimeEntryError::MissingAuthor => TimeDiagnosticKind::MissingAuthor,
            TimeEntryError::MissingId { .. }
            | TimeEntryError::NoteNotSingleLine
            | TimeEntryError::ExtensionShadowsField { .. } => TimeDiagnosticKind::MalformedEntry,
        };
        Self::new(kind, problem.to_string())
            .for_entry(issue, entry_id)
            .in_field(problem.field())
    }

    /// One filter refusal, mapped onto the shared vocabulary (T013).
    pub fn from_filter_problem(problem: &TimeFilterError) -> Self {
        let kind = match problem {
            TimeFilterError::InvalidDate { .. } => TimeDiagnosticKind::InvalidDate,
            TimeFilterError::InvertedPeriod { .. } => TimeDiagnosticKind::InvalidPeriod,
            TimeFilterError::InvalidAssignee { .. } => TimeDiagnosticKind::InvalidAssignee,
        };
        Self::new(kind, problem.message())
            .in_field(problem.field())
            .with_remedy(problem.remedy())
    }

    /// The scope named an issue the snapshot does not hold (T013).
    ///
    /// Kept apart from "the issue recorded nothing": the total for a scope the
    /// snapshot never held must not read as an issue whose entries are all absent.
    pub fn unknown_issue(issue: u32) -> Self {
        Self::new(
            TimeDiagnosticKind::UnknownIssue,
            format!(
                "issue #{issue} is not among the issues this snapshot holds, so its time was not \
                 totalled"
            ),
        )
        .for_issue(issue)
    }

    /// Name the issue the finding is about.
    pub fn for_issue(mut self, issue: u32) -> Self {
        self.issue = Some(issue);
        self
    }

    /// Name the entry the finding is about.
    pub fn for_entry(mut self, issue: u32, entry_id: &str) -> Self {
        self.issue = Some(issue);
        self.entry_id = Some(entry_id.to_string());
        self
    }

    /// Name the field the problem belongs to.
    pub fn in_field(mut self, field: &'static str) -> Self {
        self.field = Some(field);
        self
    }

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

    /// Whether the finding is about the scope rather than about one entry.
    ///
    /// The DTO's own invariant: an entry finding always names the entry it is about,
    /// and a scope finding never does.
    pub const fn is_scope_finding(&self) -> bool {
        self.entry_id.is_none()
    }

    /// Render through the crate's one diagnostic renderer, remedy and all.
    ///
    /// The headline is the reason, the notes carry the machine-readable context
    /// (kind, field, issue, entry) and the `help:` footer is the remedy — the layout
    /// [`crate::capacity::CapacityWarning::render`] uses, so a time finding and a
    /// capacity warning read the same way.
    pub fn render(&self) -> String {
        let mut diagnostic = Diagnostic::warning("E-tix-time", self.reason.clone())
            .with_note(format!("kind: {}", self.kind.as_str()));
        if let Some(field) = self.field {
            diagnostic = diagnostic.with_note(format!("field: {field}"));
        }
        if let Some(issue) = self.issue {
            diagnostic = diagnostic.with_note(format!("issue: #{issue}"));
        }
        if let Some(entry_id) = self.entry_id.as_deref() {
            let named = if entry_id.trim().is_empty() {
                "(no id)"
            } else {
                entry_id
            };
            diagnostic = diagnostic.with_note(format!("entry: {named}"));
        }
        diagnostic.with_help(&self.remedy).render()
    }
}