use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone};
use rrule::{RRule, Tz, Unvalidated};
#[derive(Debug, thiserror::Error)]
#[error("invalid recurrence rule '{rule}': {message}")]
pub struct RecurrenceError {
rule: String,
message: String,
}
pub fn validate(rule: &str) -> Result<(), RecurrenceError> {
let reference =
NaiveDate::from_ymd_opt(2000, 1, 1).ok_or_else(|| err(rule, "internal reference date"))?;
build_set(rule, reference).map(|_| ())
}
pub fn next_occurrence(
rule: &str,
anchor: NaiveDate,
after: NaiveDate,
) -> Result<Option<NaiveDate>, RecurrenceError> {
let set = build_set(rule, anchor)?;
let result = set.after(at_utc_midnight(after)).all(2);
Ok(result
.dates
.iter()
.map(DateTime::date_naive)
.find(|d| *d > after))
}
fn build_set(rule: &str, anchor: NaiveDate) -> Result<rrule::RRuleSet, RecurrenceError> {
let parsed: RRule<Unvalidated> = rule.parse().map_err(|e| err(rule, e))?;
parsed
.build(at_utc_midnight(anchor))
.map_err(|e| err(rule, e))
}
fn at_utc_midnight(date: NaiveDate) -> DateTime<Tz> {
Tz::UTC.from_utc_datetime(&date.and_time(NaiveTime::MIN))
}
fn err(rule: &str, e: impl std::fmt::Display) -> RecurrenceError {
RecurrenceError {
rule: rule.to_string(),
message: e.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn validates_good_and_bad_rules() {
assert!(validate("FREQ=WEEKLY").is_ok());
assert!(validate("FREQ=MONTHLY;BYMONTHDAY=1").is_ok());
assert!(validate("FREQ=WEEKLY;BYDAY=MO,WE,FR").is_ok());
assert!(validate("FREQ=NONSENSE").is_err());
assert!(validate("not a rule").is_err());
assert!(validate("").is_err());
}
#[test]
fn next_occurrence_basic_frequencies() {
let anchor = d(2026, 4, 14); assert_eq!(
next_occurrence("FREQ=DAILY", anchor, anchor).unwrap(),
Some(d(2026, 4, 15))
);
assert_eq!(
next_occurrence("FREQ=WEEKLY", anchor, anchor).unwrap(),
Some(d(2026, 4, 21))
);
assert_eq!(
next_occurrence("FREQ=MONTHLY", anchor, anchor).unwrap(),
Some(d(2026, 5, 14))
);
assert_eq!(
next_occurrence("FREQ=YEARLY", anchor, anchor).unwrap(),
Some(d(2027, 4, 14))
);
}
#[test]
fn next_occurrence_with_interval() {
let anchor = d(2026, 4, 14);
assert_eq!(
next_occurrence("FREQ=DAILY;INTERVAL=3", anchor, anchor).unwrap(),
Some(d(2026, 4, 17))
);
assert_eq!(
next_occurrence("FREQ=WEEKLY;INTERVAL=2", anchor, anchor).unwrap(),
Some(d(2026, 4, 28))
);
}
#[test]
fn next_occurrence_exhausted_series_is_none() {
let anchor = d(2026, 4, 14);
assert_eq!(
next_occurrence("FREQ=DAILY;COUNT=1", anchor, anchor).unwrap(),
None
);
}
}