#![allow(unused_qualifications)]
use crate::{
Property,
components::date_time::{CalendarDateTime, DatePerhapsTime},
};
use chrono::{DateTime, TimeZone as _};
pub use rrule::{self, Frequency, NWeekday, RRule, RRuleSet, Tz, Weekday};
use rrule::Unvalidated;
pub type UnvalidatedRRule = RRule<Unvalidated>;
pub(crate) fn dt_start_to_rrule_datetime(
property: &Property,
) -> Result<DateTime<rrule::Tz>, RecurrenceError> {
match DatePerhapsTime::from_property(property) {
Some(DatePerhapsTime::DateTime(CalendarDateTime::Utc(utc))) => {
Ok(rrule::Tz::UTC.from_utc_datetime(&utc.naive_utc()))
}
Some(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone { date_time, tzid })) => {
let tz: rrule::Tz = tzid
.parse::<chrono_tz::Tz>()
.map_err(|_| RecurrenceError::InvalidTimezone(tzid.clone()))?
.into();
tz.from_local_datetime(&date_time)
.single()
.ok_or(RecurrenceError::AmbiguousDateTime)
}
Some(DatePerhapsTime::DateTime(CalendarDateTime::Floating(naive))) => Ok(rrule::Tz::LOCAL
.from_local_datetime(&naive)
.single()
.ok_or(RecurrenceError::AmbiguousDateTime)?),
Some(DatePerhapsTime::Date(naive_date)) => Ok(rrule::Tz::LOCAL
.from_local_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap())
.single()
.ok_or(RecurrenceError::AmbiguousDateTime)?),
None => Err(RecurrenceError::InvalidDtStart),
}
}
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum RecurrenceError {
#[error("DTSTART must be set before calling recurrence()")]
MissingDtStart,
#[error("unrecognised timezone in DTSTART: {0}")]
InvalidTimezone(String),
#[error("the local datetime in DTSTART is ambiguous or invalid for its timezone")]
AmbiguousDateTime,
#[error("could not parse DTSTART property value")]
InvalidDtStart,
#[error("recurrence rule error: {0}")]
Rule(#[from] rrule::RRuleError),
}
#[cfg(all(test, feature = "parser"))]
mod test_recurrence_tzid {
use crate::{
Calendar, CalendarComponent, Event, EventLike, Frequency, NWeekday, RRule, Tz,
UnvalidatedRRule, Weekday, components::date_time::CalendarDateTime,
};
use chrono::{NaiveDate, TimeZone, Utc};
fn weekly_utc_rrule() -> UnvalidatedRRule {
rrule::RRule::default().count(4).freq(Frequency::Weekly)
}
#[test]
fn tzid_dtstart_preserves_timezone() {
let rrule = RRule::default()
.count(3)
.freq(Frequency::Weekly)
.by_weekday(vec![NWeekday::Every(Weekday::Mon)]);
let dt_start_ical = CalendarDateTime::WithTimezone {
date_time: NaiveDate::from_ymd_opt(2025, 6, 2)
.unwrap()
.and_hms_opt(10, 0, 0)
.unwrap(),
tzid: "Europe/Berlin".to_string(),
};
let event = Event::new()
.starts(dt_start_ical)
.recurrence(rrule)
.unwrap()
.done();
let rrule_set_out = event
.get_recurrence()
.expect("event should have a recurrence rule");
let dates = rrule_set_out.all(10).dates;
assert_eq!(dates.len(), 3);
for dt in &dates {
assert_eq!(dt.timezone(), Tz::Europe__Berlin);
assert_eq!(dt.format("%H:%M").to_string(), "10:00");
}
}
#[test]
fn tzid_dtstart_round_trips_through_serialization() {
let rrule = RRule::default()
.count(3)
.freq(Frequency::Weekly)
.by_weekday(vec![NWeekday::Every(Weekday::Mon)]);
let dt_start_ical = CalendarDateTime::WithTimezone {
date_time: NaiveDate::from_ymd_opt(2025, 6, 2)
.unwrap()
.and_hms_opt(10, 0, 0)
.unwrap(),
tzid: "Europe/Berlin".to_string(),
};
let event = Event::new()
.starts(dt_start_ical)
.recurrence(rrule)
.unwrap()
.done();
let original_dates = event
.get_recurrence()
.expect("event should have a recurrence rule")
.all(10)
.dates;
let mut calendar = Calendar::new();
calendar.push(event);
let serialized = calendar.to_string();
let reparsed: Calendar = serialized.parse().unwrap();
let reparsed_event = reparsed
.components
.iter()
.find_map(|c| {
if let CalendarComponent::Event(e) = c {
Some(e)
} else {
None
}
})
.unwrap();
let reparsed_dates = reparsed_event
.get_recurrence()
.expect("reparsed event should have a recurrence rule")
.all(10)
.dates;
assert_eq!(original_dates, reparsed_dates);
assert_eq!(reparsed_dates.len(), 3);
}
#[test]
fn utc_dtstart_still_works() {
let utc_dt = Utc.with_ymd_and_hms(2025, 3, 17, 9, 0, 0).unwrap();
let event = Event::new()
.starts(CalendarDateTime::Utc(utc_dt))
.recurrence(weekly_utc_rrule())
.unwrap()
.done();
let dates = event
.get_recurrence()
.expect("event should have a recurrence rule")
.all(10)
.dates;
assert_eq!(dates.len(), 4);
for dt in &dates {
assert_eq!(dt.timezone(), Tz::UTC);
}
}
#[test]
fn floating_dtstart_round_trips_through_serialization() {
let naive_dt = NaiveDate::from_ymd_opt(2025, 1, 1)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap();
let event = Event::new()
.starts(CalendarDateTime::Floating(naive_dt))
.recurrence(RRule::default().count(3).freq(Frequency::Daily))
.unwrap()
.done();
let original_rrule_set = event
.get_recurrence()
.expect("recurrence rule should be valid");
assert_eq!(original_rrule_set.get_dt_start().timezone(), Tz::LOCAL);
let mut calendar = Calendar::new();
calendar.push(event);
let reparsed: Calendar = calendar.to_string().parse().unwrap();
let reparsed_event = reparsed
.components
.iter()
.find_map(|c| {
if let CalendarComponent::Event(e) = c {
Some(e)
} else {
None
}
})
.unwrap();
let reparsed_rrule_set = reparsed_event
.get_recurrence()
.expect("reparsed recurrence rule should be valid");
assert_eq!(reparsed_rrule_set.get_dt_start().timezone(), Tz::LOCAL);
assert_eq!(
original_rrule_set.get_dt_start().naive_local(),
reparsed_rrule_set.get_dt_start().naive_local(),
"floating DTSTART wall-clock time must survive round-trip"
);
assert_eq!(reparsed_rrule_set.all(10).dates.len(), 3);
}
#[test]
fn all_day_dtstart_round_trips_through_serialization() {
let naive_date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let event = Event::new()
.all_day(naive_date)
.recurrence(RRule::default().count(3).freq(Frequency::Daily))
.unwrap()
.done();
let original_rrule_set = event
.get_recurrence()
.expect("recurrence rule should be valid");
assert_eq!(original_rrule_set.get_dt_start().timezone(), Tz::LOCAL);
let mut calendar = Calendar::new();
calendar.push(event);
let reparsed: Calendar = calendar.to_string().parse().unwrap();
let reparsed_event = reparsed
.components
.iter()
.find_map(|c| {
if let CalendarComponent::Event(e) = c {
Some(e)
} else {
None
}
})
.unwrap();
let reparsed_rrule_set = reparsed_event
.get_recurrence()
.expect("reparsed recurrence rule should be valid");
assert_eq!(reparsed_rrule_set.get_dt_start().timezone(), Tz::LOCAL);
assert_eq!(
original_rrule_set.get_dt_start().naive_local(),
reparsed_rrule_set.get_dt_start().naive_local(),
"all-day DTSTART midnight must survive round-trip"
);
assert_eq!(reparsed_rrule_set.all(10).dates.len(), 3);
}
}
#[cfg(test)]
mod test_recurrence_errors {
use crate::{
Component, Event, EventLike as _, Frequency, RRule, RecurrenceError,
components::date_time::CalendarDateTime,
};
use chrono::{NaiveDate, TimeZone, Utc};
#[test]
fn no_rrule_returns_single_occurrence() {
let dt = Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap();
let event = Event::new().starts(dt).done();
let rruleset = event
.get_recurrence()
.expect("expected Ok even without RRULE");
let dates = rruleset.all(10).dates;
assert_eq!(dates.len(), 1);
assert_eq!(dates.first().unwrap().timestamp(), dt.timestamp());
}
#[test]
fn valid_rrule_returns_some() {
let event = Event::new()
.starts(Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap())
.recurrence(RRule::default().count(3).freq(Frequency::Daily))
.unwrap()
.done();
assert!(event.get_recurrence().is_ok());
}
#[test]
fn invalid_rrule_returns_none_and_some_err() {
let event = Event::new()
.starts(Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap())
.add_property("RRULE", "THIS IS NOT VALID")
.done();
assert!(event.get_recurrence().is_err());
assert!(matches!(
event.get_recurrence(),
Err(RecurrenceError::Rule(_))
));
}
#[test]
fn missing_dtstart_returns_error() {
let mut event = Event::new();
let result = event.recurrence(RRule::default().freq(Frequency::Daily));
assert!(matches!(result, Err(RecurrenceError::MissingDtStart)));
}
#[test]
fn invalid_timezone_returns_error() {
let dt_start = CalendarDateTime::WithTimezone {
date_time: NaiveDate::from_ymd_opt(2025, 1, 1)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: "Not/ATimezone".to_string(),
};
let mut event = Event::new();
event.starts(dt_start);
let result = event.recurrence(RRule::default().freq(Frequency::Daily));
assert!(matches!(
result,
Err(RecurrenceError::InvalidTimezone(tz)) if tz == "Not/ATimezone"
));
}
}
#[cfg(test)]
mod test_rdates {
use std::vec;
use chrono::TimeZone as _;
use chrono_tz::Europe::Berlin;
use crate::{
Calendar, Component, Event, EventLike as _, components::date_time::CalendarDateTime,
};
#[test]
fn use_rdates_for_recurrence() {
let mut all_hands = Event::new()
.uid("all_hands_2026@example.com")
.summary("All-Hands Meeting")
.description("Monthly all-hands. First Monday of each month, 09:00–10:00 Berlin time.")
.starts(CalendarDateTime::from_ymd_hm_tzid(2026, 1, 5, 9, 0, Berlin).unwrap())
.ends(CalendarDateTime::from_ymd_hm_tzid(2026, 1, 5, 10, 0, Berlin).unwrap())
.rdate(CalendarDateTime::from_ymd_hm_tzid(2026, 1, 6, 9, 0, Berlin).unwrap())
.rdate(CalendarDateTime::from_ymd_hm_tzid(2026, 1, 7, 9, 0, Berlin).unwrap())
.rdate(CalendarDateTime::from_ymd_hm_tzid(2026, 1, 8, 9, 0, Berlin).unwrap())
.done();
let december_instance =
CalendarDateTime::from_ymd_hm_tzid(2026, 12, 7, 9, 0, Berlin).unwrap();
all_hands.exdate(december_instance);
let mut calendar = Calendar::new();
calendar.push(all_hands);
let recurrences = calendar
.events()
.next()
.unwrap()
.to_owned()
.get_recurrence()
.unwrap()
.all(100)
.dates;
let expected = vec![
Berlin.ymd(2026, 1, 5).and_hms(9, 0, 0),
Berlin.ymd(2026, 1, 6).and_hms(9, 0, 0),
Berlin.ymd(2026, 1, 7).and_hms(9, 0, 0),
Berlin.ymd(2026, 1, 8).and_hms(9, 0, 0),
]
.into_iter()
.collect::<Vec<_>>();
assert_eq!(
recurrences
.into_iter()
.map(|dt| dt.with_timezone(&chrono_tz::Europe::Berlin))
.collect::<Vec<_>>(),
expected
);
}
}
#[cfg(test)]
mod test_recurrence_properties {
use crate::{
Component, Event, EventLike as _, Frequency, RRule, Tz,
components::date_time::CalendarDateTime,
};
use chrono::{NaiveDate, TimeZone as _, Utc};
use super::dt_start_to_rrule_datetime;
#[test]
fn no_spurious_rdate_or_exdate_properties() {
let event = Event::new()
.starts(Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap())
.recurrence(RRule::default().count(3).freq(Frequency::Daily))
.unwrap()
.done();
let multi = event.multi_properties();
assert!(
!multi.contains_key("RDATE"),
"RDATE should not be present when no RDATEs were supplied"
);
assert!(
!multi.contains_key("EXDATE"),
"EXDATE should not be present when no EXDATEs were supplied"
);
}
#[test]
fn serialized_output_contains_no_blank_rdate_or_exdate() {
let event = Event::new()
.starts(Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap())
.recurrence(RRule::default().count(3).freq(Frequency::Daily))
.unwrap()
.done();
let ics = event.to_string();
for line in ics.lines() {
let key = line.split(':').next().unwrap_or("");
assert!(
key != "RDATE",
"unexpected blank RDATE line in serialized output"
);
assert!(
key != "EXDATE",
"unexpected blank EXDATE line in serialized output"
);
}
}
#[test]
fn floating_dtstart_produces_correct_occurrences() {
let naive_dt = NaiveDate::from_ymd_opt(2025, 1, 1)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap();
let prop = CalendarDateTime::Floating(naive_dt).to_property("DTSTART");
let dt_start =
dt_start_to_rrule_datetime(&prop).expect("floating DTSTART should be convertible");
assert_eq!(
dt_start.timezone(),
Tz::LOCAL,
"floating DTSTART must be interpreted as Tz::LOCAL (not Tz::UTC) to match rrule's string parser"
);
assert_eq!(dt_start.naive_local().time(), naive_dt.time());
}
#[test]
fn all_day_dtstart_produces_correct_occurrences() {
let naive_date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
use crate::components::date_time::naive_date_to_property;
let prop = naive_date_to_property(naive_date, "DTSTART");
let dt_start =
dt_start_to_rrule_datetime(&prop).expect("all-day DTSTART should be convertible");
assert_eq!(
dt_start.timezone(),
Tz::LOCAL,
"all-day DTSTART must be interpreted as Tz::LOCAL (not Tz::UTC) to match rrule's string parser"
);
assert_eq!(
dt_start.naive_local().time(),
chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()
);
}
}