1use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone};
16use rrule::{RRule, Tz, Unvalidated};
17
18#[derive(Debug, thiserror::Error)]
19#[error("invalid recurrence rule '{rule}': {message}")]
20pub struct RecurrenceError {
21 rule: String,
22 message: String,
23}
24
25pub fn validate(rule: &str) -> Result<(), RecurrenceError> {
28 let reference =
31 NaiveDate::from_ymd_opt(2000, 1, 1).ok_or_else(|| err(rule, "internal reference date"))?;
32 build_set(rule, reference).map(|_| ())
33}
34
35pub fn next_occurrence(
40 rule: &str,
41 anchor: NaiveDate,
42 after: NaiveDate,
43) -> Result<Option<NaiveDate>, RecurrenceError> {
44 let set = build_set(rule, anchor)?;
45 let result = set.after(at_utc_midnight(after)).all(2);
51 Ok(result
52 .dates
53 .iter()
54 .map(DateTime::date_naive)
55 .find(|d| *d > after))
56}
57
58fn build_set(rule: &str, anchor: NaiveDate) -> Result<rrule::RRuleSet, RecurrenceError> {
60 let parsed: RRule<Unvalidated> = rule.parse().map_err(|e| err(rule, e))?;
61 parsed
62 .build(at_utc_midnight(anchor))
63 .map_err(|e| err(rule, e))
64}
65
66fn at_utc_midnight(date: NaiveDate) -> DateTime<Tz> {
68 Tz::UTC.from_utc_datetime(&date.and_time(NaiveTime::MIN))
69}
70
71fn err(rule: &str, e: impl std::fmt::Display) -> RecurrenceError {
72 RecurrenceError {
73 rule: rule.to_string(),
74 message: e.to_string(),
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 fn d(y: i32, m: u32, day: u32) -> NaiveDate {
83 NaiveDate::from_ymd_opt(y, m, day).unwrap()
84 }
85
86 #[test]
87 fn validates_good_and_bad_rules() {
88 assert!(validate("FREQ=WEEKLY").is_ok());
89 assert!(validate("FREQ=MONTHLY;BYMONTHDAY=1").is_ok());
90 assert!(validate("FREQ=WEEKLY;BYDAY=MO,WE,FR").is_ok());
91 assert!(validate("FREQ=NONSENSE").is_err());
92 assert!(validate("not a rule").is_err());
93 assert!(validate("").is_err());
94 }
95
96 #[test]
97 fn next_occurrence_basic_frequencies() {
98 let anchor = d(2026, 4, 14); assert_eq!(
100 next_occurrence("FREQ=DAILY", anchor, anchor).unwrap(),
101 Some(d(2026, 4, 15))
102 );
103 assert_eq!(
104 next_occurrence("FREQ=WEEKLY", anchor, anchor).unwrap(),
105 Some(d(2026, 4, 21))
106 );
107 assert_eq!(
108 next_occurrence("FREQ=MONTHLY", anchor, anchor).unwrap(),
109 Some(d(2026, 5, 14))
110 );
111 assert_eq!(
112 next_occurrence("FREQ=YEARLY", anchor, anchor).unwrap(),
113 Some(d(2027, 4, 14))
114 );
115 }
116
117 #[test]
118 fn next_occurrence_with_interval() {
119 let anchor = d(2026, 4, 14);
120 assert_eq!(
121 next_occurrence("FREQ=DAILY;INTERVAL=3", anchor, anchor).unwrap(),
122 Some(d(2026, 4, 17))
123 );
124 assert_eq!(
125 next_occurrence("FREQ=WEEKLY;INTERVAL=2", anchor, anchor).unwrap(),
126 Some(d(2026, 4, 28))
127 );
128 }
129
130 #[test]
131 fn next_occurrence_exhausted_series_is_none() {
132 let anchor = d(2026, 4, 14);
133 assert_eq!(
135 next_occurrence("FREQ=DAILY;COUNT=1", anchor, anchor).unwrap(),
136 None
137 );
138 }
139}