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}