tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Read-only due detection for recurring series (mission
//! `recurring-issues-01M3C7G7`, WP03; `data-model.md` §4/§5 and
//! `contracts/series-generator.md`).
//!
//! # Detection is a report, never a mutation
//!
//! [`detect`] takes an index the caller already built (WP02), a series snapshot
//! inside it, and a **supplied** date context (WP01). It returns the occurrences
//! that are due right now and the explicit actions a surface can offer for each.
//! It opens no file, writes no History entry, moves no cursor and marks no series
//! "seen" — `C-003` says detection on start/refresh is read-only, and the only way
//! to guarantee that is to give it nothing to write with: no `Store`, no clock, no
//! `&mut` of anything.
//!
//! # One candidate per series, and why there is no forward scan
//!
//! A series' next candidate is its **cursor** (`series.next_cursor`, the last date
//! its definition has consumed) or, before anything was ever consumed, its rule's
//! anchor date. The candidate is due when its canonical key is at or before the
//! supplied date *and* the key is free.
//!
//! Deciding *what comes after* a consumed candidate is deliberately **not** here.
//! It belongs to the operation that consumes one (WP04's confirmed generation,
//! WP05's lifecycle: skip and defer record their next candidate), and a detector
//! that re-derived it would be a second scheduler that could disagree with the
//! first. Detection reads the cursor the writers maintain; when the cursor names a
//! key that is already recorded, that is reported as
//! [`DetectionNoteKind::CursorAlreadyTaken`] instead of guessed past — the series
//! is not due, and the note says why.
//!
//! Because keys are `YYYY-MM-DD`, "at or before today" is a plain string
//! comparison: the canonical form sorts chronologically, which is exactly the
//! property [`crate::recurrence::rule::DateKey`] promises (C-002/C-006).
//!
//! # Ambiguity is refused upstream, not papered over here
//!
//! A rule that does not validate, or a series whose state or dates the index
//! already reported as inconsistent, produces **no** warning: warning about a
//! schedule nobody can evaluate would be worse than silence. The findings are in
//! [`SeriesIndex::diagnostics`], which is where a surface renders them.

use crate::recurrence::rule::{DateKey, RuleContext};
use crate::recurrence::series::{OccurrenceKey, Series, SeriesId, SeriesIndex};

/// One decision a surface can offer for a warning (`data-model.md` §5).
///
/// The list is the whole vocabulary: a warning is a *question* with a closed set
/// of answers, so no surface invents a fifth one, and every answer is an explicit
/// action that only a confirmed operation carries out.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WarningAction {
    /// Create the occurrence (WP04, confirmed).
    Generate,
    /// Move the candidate on without creating it (WP05).
    Defer,
    /// Record the candidate as skipped (WP05).
    Ignore,
    /// Close the warning without deciding (`C-003`: closing changes nothing).
    Close,
}

impl WarningAction {
    /// Every action, in the order a surface should offer them.
    pub const ALL: [Self; 4] = [Self::Generate, Self::Defer, Self::Ignore, Self::Close];

    /// The stable spelling the CLI and the MCP tools print.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Generate => "generate",
            Self::Defer => "defer",
            Self::Ignore => "ignore",
            Self::Close => "close",
        }
    }
}

/// One due occurrence, as a self-contained question.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DueWarning {
    /// The series it belongs to.
    pub series_id: SeriesId,
    /// The series' display name, for the sentence a user reads.
    pub series_name: String,
    /// The candidate's stable identity — what a decision is recorded against.
    pub occurrence_key: OccurrenceKey,
    /// The candidate's canonical date.
    pub due_date: DateKey,
    /// The rule in words, so a warning explains itself without the definition.
    pub rule_summary: String,
    /// The explicit decisions this warning accepts.
    pub available_actions: Vec<WarningAction>,
}

impl DueWarning {
    /// The one-line sentence a surface shows: `Series X is due on DATE`.
    pub fn summary(&self) -> String {
        format!(
            "{} is due on {} ({})",
            self.series_name, self.due_date, self.rule_summary
        )
    }
}

/// A reason detection produced no warning for a series it looked at.
///
/// A note is information, never an instruction: it names the state that made the
/// series not-due, so a surface can say "nothing to do, and here is why" instead
/// of an empty list that reads like a bug.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetectionNoteKind {
    /// The series is paused: generation is suspended until an explicit resume.
    Paused,
    /// The series has ended: terminal for generation.
    Ended,
    /// The cursor names a key that is already generated or skipped, so nothing is
    /// due until the cursor moves — the writers that consumed it own that step.
    CursorAlreadyTaken,
}

impl DetectionNoteKind {
    /// The stable spelling the CLI and the MCP tools print.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Paused => "series_paused",
            Self::Ended => "series_ended",
            Self::CursorAlreadyTaken => "cursor_already_taken",
        }
    }
}

/// One note about one series.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectionNote {
    /// The series the note is about.
    pub series_id: SeriesId,
    /// Why it produced no warning.
    pub kind: DetectionNoteKind,
    /// One sentence saying what the state means.
    pub message: String,
}

/// What one detection pass found.
///
/// Both lists are in a deterministic order — warnings by due date, then series
/// id; notes by series id — so two runs over the same input produce byte-identical
/// output, which is what "repeated detection is deterministic" means in practice.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Detection {
    warnings: Vec<DueWarning>,
    notes: Vec<DetectionNote>,
    series_examined: usize,
}

impl Detection {
    /// The due occurrences, oldest first.
    pub fn warnings(&self) -> &[DueWarning] {
        &self.warnings
    }

    /// The series that produced no warning, and why.
    pub fn notes(&self) -> &[DetectionNote] {
        &self.notes
    }

    /// How many series the pass looked at, including the ones it skipped.
    pub fn series_examined(&self) -> usize {
        self.series_examined
    }

    /// Whether nothing is due.
    pub fn is_empty(&self) -> bool {
        self.warnings.is_empty()
    }

    /// The warning for `key`, if one is due.
    pub fn warning_for(&self, key: &OccurrenceKey) -> Option<&DueWarning> {
        self.warnings
            .iter()
            .find(|warning| &warning.occurrence_key == key)
    }
}

/// The detector every surface calls on start/refresh (T015).
///
/// One pass over `index`; no I/O, no clock, no writes. `context` supplies the
/// local date a caller has already resolved (`RuleContext`), so the same input
/// always produces the same output — which is what makes a start-time warning and
/// a headless `tix series due` run agree (C-008).
pub fn detect(index: &SeriesIndex, context: &RuleContext) -> Detection {
    let today = DateKey::from_date(context.today);
    let mut detection = Detection {
        series_examined: index.series_count(),
        ..Detection::default()
    };
    for series in index.series() {
        match candidate(series, index, &today) {
            Candidate::Due { date, key } => detection.warnings.push(DueWarning {
                series_id: series.id.clone(),
                series_name: series.name.clone(),
                occurrence_key: key,
                due_date: date,
                rule_summary: series.rule.summary(),
                available_actions: WarningAction::ALL.to_vec(),
            }),
            Candidate::NotDue => {}
            Candidate::Note(kind, message) => detection.notes.push(DetectionNote {
                series_id: series.id.clone(),
                kind,
                message,
            }),
        }
    }
    // Oldest due first, then by series id: two runs agree, and a surface that
    // shows the first warning shows the most overdue one.
    detection.warnings.sort_by(|a, b| {
        a.due_date
            .cmp(&b.due_date)
            .then(a.series_id.cmp(&b.series_id))
    });
    detection
        .notes
        .sort_by(|a, b| a.series_id.cmp(&b.series_id));
    detection
}

/// What one series contributed to a pass.
enum Candidate {
    /// The candidate is at or before today and its key is free.
    Due { date: DateKey, key: OccurrenceKey },
    /// Nothing to report and nothing to explain: the candidate is in the future.
    NotDue,
    /// Nothing to report, with a reason worth stating.
    Note(DetectionNoteKind, String),
}

fn candidate(series: &Series, index: &SeriesIndex, today: &DateKey) -> Candidate {
    if !series.allows_generation() {
        // Paused and ended are both "no warning", but they are not the same state
        // and a surface that said so would be guessing; the note names which.
        return match series.state {
            crate::recurrence::series::SeriesLifecycle::Ended => Candidate::Note(
                DetectionNoteKind::Ended,
                format!("series `{}` has ended", series.id),
            ),
            _ => Candidate::Note(
                DetectionNoteKind::Paused,
                format!("series `{}` is paused", series.id),
            ),
        };
    }
    // A rule that cannot be evaluated is refused, not guessed at: the index's own
    // diagnostics carry the finding, and a warning here would describe a schedule
    // nobody can act on.
    let Ok(anchor) = series.rule.anchor_key() else {
        return Candidate::NotDue;
    };
    let date = series.next_cursor.clone().unwrap_or(anchor);
    if date.as_str() > today.as_str() {
        return Candidate::NotDue;
    }
    let key = series.key_for(&date);
    if index.is_taken(&key) {
        // The cursor names a key that already exists (generated or skipped). The
        // step past it belongs to the operation that consumed it; guessing here
        // would be a second scheduler.
        return Candidate::Note(
            DetectionNoteKind::CursorAlreadyTaken,
            format!(
                "series `{}` cursor `{date}` is already recorded; nothing is due until it moves",
                series.id
            ),
        );
    }
    Candidate::Due { date, key }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::recurrence::rule::RuleKind;
    use crate::recurrence::series::SeriesLifecycle;

    #[test]
    fn every_action_has_a_stable_spelling_and_a_fixed_order() {
        let spellings: Vec<&str> = WarningAction::ALL.iter().map(|a| a.as_str()).collect();
        assert_eq!(spellings, vec!["generate", "defer", "ignore", "close"]);
        assert_eq!(WarningAction::ALL.len(), 4);
    }

    #[test]
    fn the_notes_have_stable_spellings() {
        assert_eq!(DetectionNoteKind::Paused.as_str(), "series_paused");
        assert_eq!(DetectionNoteKind::Ended.as_str(), "series_ended");
        assert_eq!(
            DetectionNoteKind::CursorAlreadyTaken.as_str(),
            "cursor_already_taken"
        );
    }

    #[test]
    fn the_lifecycle_states_that_block_generation_are_the_ones_noted() {
        assert!(!SeriesLifecycle::Paused.allows_generation());
        assert!(!SeriesLifecycle::Ended.allows_generation());
        assert!(SeriesLifecycle::Active.allows_generation());
        assert_eq!(RuleKind::Daily.as_str(), "daily");
    }
}