Skip to main content

tix/recurrence/
detect.rs

1//! Read-only due detection for recurring series (mission
2//! `recurring-issues-01M3C7G7`, WP03; `data-model.md` §4/§5 and
3//! `contracts/series-generator.md`).
4//!
5//! # Detection is a report, never a mutation
6//!
7//! [`detect`] takes an index the caller already built (WP02), a series snapshot
8//! inside it, and a **supplied** date context (WP01). It returns the occurrences
9//! that are due right now and the explicit actions a surface can offer for each.
10//! It opens no file, writes no History entry, moves no cursor and marks no series
11//! "seen" — `C-003` says detection on start/refresh is read-only, and the only way
12//! to guarantee that is to give it nothing to write with: no `Store`, no clock, no
13//! `&mut` of anything.
14//!
15//! # One candidate per series, and why there is no forward scan
16//!
17//! A series' next candidate is its **cursor** (`series.next_cursor`, the last date
18//! its definition has consumed) or, before anything was ever consumed, its rule's
19//! anchor date. The candidate is due when its canonical key is at or before the
20//! supplied date *and* the key is free.
21//!
22//! Deciding *what comes after* a consumed candidate is deliberately **not** here.
23//! It belongs to the operation that consumes one (WP04's confirmed generation,
24//! WP05's lifecycle: skip and defer record their next candidate), and a detector
25//! that re-derived it would be a second scheduler that could disagree with the
26//! first. Detection reads the cursor the writers maintain; when the cursor names a
27//! key that is already recorded, that is reported as
28//! [`DetectionNoteKind::CursorAlreadyTaken`] instead of guessed past — the series
29//! is not due, and the note says why.
30//!
31//! Because keys are `YYYY-MM-DD`, "at or before today" is a plain string
32//! comparison: the canonical form sorts chronologically, which is exactly the
33//! property [`crate::recurrence::rule::DateKey`] promises (C-002/C-006).
34//!
35//! # Ambiguity is refused upstream, not papered over here
36//!
37//! A rule that does not validate, or a series whose state or dates the index
38//! already reported as inconsistent, produces **no** warning: warning about a
39//! schedule nobody can evaluate would be worse than silence. The findings are in
40//! [`SeriesIndex::diagnostics`], which is where a surface renders them.
41
42use crate::recurrence::rule::{DateKey, RuleContext};
43use crate::recurrence::series::{OccurrenceKey, Series, SeriesId, SeriesIndex};
44
45/// One decision a surface can offer for a warning (`data-model.md` §5).
46///
47/// The list is the whole vocabulary: a warning is a *question* with a closed set
48/// of answers, so no surface invents a fifth one, and every answer is an explicit
49/// action that only a confirmed operation carries out.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum WarningAction {
52    /// Create the occurrence (WP04, confirmed).
53    Generate,
54    /// Move the candidate on without creating it (WP05).
55    Defer,
56    /// Record the candidate as skipped (WP05).
57    Ignore,
58    /// Close the warning without deciding (`C-003`: closing changes nothing).
59    Close,
60}
61
62impl WarningAction {
63    /// Every action, in the order a surface should offer them.
64    pub const ALL: [Self; 4] = [Self::Generate, Self::Defer, Self::Ignore, Self::Close];
65
66    /// The stable spelling the CLI and the MCP tools print.
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Self::Generate => "generate",
70            Self::Defer => "defer",
71            Self::Ignore => "ignore",
72            Self::Close => "close",
73        }
74    }
75}
76
77/// One due occurrence, as a self-contained question.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct DueWarning {
80    /// The series it belongs to.
81    pub series_id: SeriesId,
82    /// The series' display name, for the sentence a user reads.
83    pub series_name: String,
84    /// The candidate's stable identity — what a decision is recorded against.
85    pub occurrence_key: OccurrenceKey,
86    /// The candidate's canonical date.
87    pub due_date: DateKey,
88    /// The rule in words, so a warning explains itself without the definition.
89    pub rule_summary: String,
90    /// The explicit decisions this warning accepts.
91    pub available_actions: Vec<WarningAction>,
92}
93
94impl DueWarning {
95    /// The one-line sentence a surface shows: `Series X is due on DATE`.
96    pub fn summary(&self) -> String {
97        format!(
98            "{} is due on {} ({})",
99            self.series_name, self.due_date, self.rule_summary
100        )
101    }
102}
103
104/// A reason detection produced no warning for a series it looked at.
105///
106/// A note is information, never an instruction: it names the state that made the
107/// series not-due, so a surface can say "nothing to do, and here is why" instead
108/// of an empty list that reads like a bug.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub enum DetectionNoteKind {
111    /// The series is paused: generation is suspended until an explicit resume.
112    Paused,
113    /// The series has ended: terminal for generation.
114    Ended,
115    /// The cursor names a key that is already generated or skipped, so nothing is
116    /// due until the cursor moves — the writers that consumed it own that step.
117    CursorAlreadyTaken,
118}
119
120impl DetectionNoteKind {
121    /// The stable spelling the CLI and the MCP tools print.
122    pub const fn as_str(self) -> &'static str {
123        match self {
124            Self::Paused => "series_paused",
125            Self::Ended => "series_ended",
126            Self::CursorAlreadyTaken => "cursor_already_taken",
127        }
128    }
129}
130
131/// One note about one series.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct DetectionNote {
134    /// The series the note is about.
135    pub series_id: SeriesId,
136    /// Why it produced no warning.
137    pub kind: DetectionNoteKind,
138    /// One sentence saying what the state means.
139    pub message: String,
140}
141
142/// What one detection pass found.
143///
144/// Both lists are in a deterministic order — warnings by due date, then series
145/// id; notes by series id — so two runs over the same input produce byte-identical
146/// output, which is what "repeated detection is deterministic" means in practice.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct Detection {
149    warnings: Vec<DueWarning>,
150    notes: Vec<DetectionNote>,
151    series_examined: usize,
152}
153
154impl Detection {
155    /// The due occurrences, oldest first.
156    pub fn warnings(&self) -> &[DueWarning] {
157        &self.warnings
158    }
159
160    /// The series that produced no warning, and why.
161    pub fn notes(&self) -> &[DetectionNote] {
162        &self.notes
163    }
164
165    /// How many series the pass looked at, including the ones it skipped.
166    pub fn series_examined(&self) -> usize {
167        self.series_examined
168    }
169
170    /// Whether nothing is due.
171    pub fn is_empty(&self) -> bool {
172        self.warnings.is_empty()
173    }
174
175    /// The warning for `key`, if one is due.
176    pub fn warning_for(&self, key: &OccurrenceKey) -> Option<&DueWarning> {
177        self.warnings
178            .iter()
179            .find(|warning| &warning.occurrence_key == key)
180    }
181}
182
183/// The detector every surface calls on start/refresh (T015).
184///
185/// One pass over `index`; no I/O, no clock, no writes. `context` supplies the
186/// local date a caller has already resolved (`RuleContext`), so the same input
187/// always produces the same output — which is what makes a start-time warning and
188/// a headless `tix series due` run agree (C-008).
189pub fn detect(index: &SeriesIndex, context: &RuleContext) -> Detection {
190    let today = DateKey::from_date(context.today);
191    let mut detection = Detection {
192        series_examined: index.series_count(),
193        ..Detection::default()
194    };
195    for series in index.series() {
196        match candidate(series, index, &today) {
197            Candidate::Due { date, key } => detection.warnings.push(DueWarning {
198                series_id: series.id.clone(),
199                series_name: series.name.clone(),
200                occurrence_key: key,
201                due_date: date,
202                rule_summary: series.rule.summary(),
203                available_actions: WarningAction::ALL.to_vec(),
204            }),
205            Candidate::NotDue => {}
206            Candidate::Note(kind, message) => detection.notes.push(DetectionNote {
207                series_id: series.id.clone(),
208                kind,
209                message,
210            }),
211        }
212    }
213    // Oldest due first, then by series id: two runs agree, and a surface that
214    // shows the first warning shows the most overdue one.
215    detection.warnings.sort_by(|a, b| {
216        a.due_date
217            .cmp(&b.due_date)
218            .then(a.series_id.cmp(&b.series_id))
219    });
220    detection
221        .notes
222        .sort_by(|a, b| a.series_id.cmp(&b.series_id));
223    detection
224}
225
226/// What one series contributed to a pass.
227enum Candidate {
228    /// The candidate is at or before today and its key is free.
229    Due { date: DateKey, key: OccurrenceKey },
230    /// Nothing to report and nothing to explain: the candidate is in the future.
231    NotDue,
232    /// Nothing to report, with a reason worth stating.
233    Note(DetectionNoteKind, String),
234}
235
236fn candidate(series: &Series, index: &SeriesIndex, today: &DateKey) -> Candidate {
237    if !series.allows_generation() {
238        // Paused and ended are both "no warning", but they are not the same state
239        // and a surface that said so would be guessing; the note names which.
240        return match series.state {
241            crate::recurrence::series::SeriesLifecycle::Ended => Candidate::Note(
242                DetectionNoteKind::Ended,
243                format!("series `{}` has ended", series.id),
244            ),
245            _ => Candidate::Note(
246                DetectionNoteKind::Paused,
247                format!("series `{}` is paused", series.id),
248            ),
249        };
250    }
251    // A rule that cannot be evaluated is refused, not guessed at: the index's own
252    // diagnostics carry the finding, and a warning here would describe a schedule
253    // nobody can act on.
254    let Ok(anchor) = series.rule.anchor_key() else {
255        return Candidate::NotDue;
256    };
257    let date = series.next_cursor.clone().unwrap_or(anchor);
258    if date.as_str() > today.as_str() {
259        return Candidate::NotDue;
260    }
261    let key = series.key_for(&date);
262    if index.is_taken(&key) {
263        // The cursor names a key that already exists (generated or skipped). The
264        // step past it belongs to the operation that consumed it; guessing here
265        // would be a second scheduler.
266        return Candidate::Note(
267            DetectionNoteKind::CursorAlreadyTaken,
268            format!(
269                "series `{}` cursor `{date}` is already recorded; nothing is due until it moves",
270                series.id
271            ),
272        );
273    }
274    Candidate::Due { date, key }
275}
276
277#[cfg(test)]
278mod tests {
279    #[allow(clippy::wildcard_imports)]
280    use super::*;
281    use crate::recurrence::rule::RuleKind;
282    use crate::recurrence::series::SeriesLifecycle;
283
284    #[test]
285    fn every_action_has_a_stable_spelling_and_a_fixed_order() {
286        let spellings: Vec<&str> = WarningAction::ALL.iter().map(|a| a.as_str()).collect();
287        assert_eq!(spellings, vec!["generate", "defer", "ignore", "close"]);
288        assert_eq!(WarningAction::ALL.len(), 4);
289    }
290
291    #[test]
292    fn the_notes_have_stable_spellings() {
293        assert_eq!(DetectionNoteKind::Paused.as_str(), "series_paused");
294        assert_eq!(DetectionNoteKind::Ended.as_str(), "series_ended");
295        assert_eq!(
296            DetectionNoteKind::CursorAlreadyTaken.as_str(),
297            "cursor_already_taken"
298        );
299    }
300
301    #[test]
302    fn the_lifecycle_states_that_block_generation_are_the_ones_noted() {
303        assert!(!SeriesLifecycle::Paused.allows_generation());
304        assert!(!SeriesLifecycle::Ended.allows_generation());
305        assert!(SeriesLifecycle::Active.allows_generation());
306        assert_eq!(RuleKind::Daily.as_str(), "daily");
307    }
308}