use crate::recurrence::rule::{DateKey, RuleContext};
use crate::recurrence::series::{OccurrenceKey, Series, SeriesId, SeriesIndex};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WarningAction {
Generate,
Defer,
Ignore,
Close,
}
impl WarningAction {
pub const ALL: [Self; 4] = [Self::Generate, Self::Defer, Self::Ignore, Self::Close];
pub const fn as_str(self) -> &'static str {
match self {
Self::Generate => "generate",
Self::Defer => "defer",
Self::Ignore => "ignore",
Self::Close => "close",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DueWarning {
pub series_id: SeriesId,
pub series_name: String,
pub occurrence_key: OccurrenceKey,
pub due_date: DateKey,
pub rule_summary: String,
pub available_actions: Vec<WarningAction>,
}
impl DueWarning {
pub fn summary(&self) -> String {
format!(
"{} is due on {} ({})",
self.series_name, self.due_date, self.rule_summary
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetectionNoteKind {
Paused,
Ended,
CursorAlreadyTaken,
}
impl DetectionNoteKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Paused => "series_paused",
Self::Ended => "series_ended",
Self::CursorAlreadyTaken => "cursor_already_taken",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectionNote {
pub series_id: SeriesId,
pub kind: DetectionNoteKind,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Detection {
warnings: Vec<DueWarning>,
notes: Vec<DetectionNote>,
series_examined: usize,
}
impl Detection {
pub fn warnings(&self) -> &[DueWarning] {
&self.warnings
}
pub fn notes(&self) -> &[DetectionNote] {
&self.notes
}
pub fn series_examined(&self) -> usize {
self.series_examined
}
pub fn is_empty(&self) -> bool {
self.warnings.is_empty()
}
pub fn warning_for(&self, key: &OccurrenceKey) -> Option<&DueWarning> {
self.warnings
.iter()
.find(|warning| &warning.occurrence_key == key)
}
}
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,
}),
}
}
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
}
enum Candidate {
Due { date: DateKey, key: OccurrenceKey },
NotDue,
Note(DetectionNoteKind, String),
}
fn candidate(series: &Series, index: &SeriesIndex, today: &DateKey) -> Candidate {
if !series.allows_generation() {
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),
),
};
}
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) {
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 {
#[allow(clippy::wildcard_imports)]
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");
}
}