use crate::error::Result;
use crate::timezone::resolve_local;
use chrono::{DateTime, Datelike};
use chrono_tz::Tz;
use rrule::Frequency;
#[derive(Debug, Clone)]
pub struct Recurrence {
frequency: Frequency,
interval: u16,
count: Option<u32>,
until: Option<DateTime<Tz>>,
by_weekday: Option<Vec<rrule::Weekday>>,
}
impl Recurrence {
pub fn new(frequency: Frequency) -> Self {
Self {
frequency,
interval: 1,
count: None,
until: None,
by_weekday: None,
}
}
pub fn daily() -> Self {
Self::new(Frequency::Daily)
}
pub fn weekly() -> Self {
Self::new(Frequency::Weekly)
}
pub fn monthly() -> Self {
Self::new(Frequency::Monthly)
}
pub fn yearly() -> Self {
Self::new(Frequency::Yearly)
}
pub fn hourly() -> Self {
Self::new(Frequency::Hourly)
}
pub fn minutely() -> Self {
Self::new(Frequency::Minutely)
}
pub fn secondly() -> Self {
Self::new(Frequency::Secondly)
}
pub fn interval(mut self, interval: u16) -> Self {
self.interval = if interval == 0 {
1
} else {
interval
};
self
}
pub fn count(mut self, count: u32) -> Self {
self.count = Some(count);
self
}
pub fn until(mut self, until: DateTime<Tz>) -> Self {
self.until = Some(until);
self
}
pub fn weekdays(mut self, weekdays: Vec<rrule::Weekday>) -> Self {
self.by_weekday = if weekdays.is_empty() {
None
} else {
Some(weekdays)
};
self
}
pub fn frequency(&self) -> Frequency {
self.frequency
}
pub fn get_interval(&self) -> u16 {
self.interval
}
pub fn get_count(&self) -> Option<u32> {
self.count
}
pub fn get_until(&self) -> Option<DateTime<Tz>> {
self.until
}
pub fn get_weekdays(&self) -> Option<&[rrule::Weekday]> {
self.by_weekday.as_deref()
}
pub fn to_rrule_string(&self, dtstart: DateTime<Tz>) -> Result<String> {
let mut rrule_str = format!("FREQ={:?}", self.frequency).to_uppercase();
if self.interval > 1 {
rrule_str.push_str(&format!(";INTERVAL={}", self.interval));
}
if let Some(count) = self.count {
rrule_str.push_str(&format!(";COUNT={}", count));
}
if let Some(until) = self.until {
let until_utc = until.with_timezone(&chrono::Utc);
let until_str = until_utc.format("%Y%m%dT%H%M%SZ").to_string();
rrule_str.push_str(&format!(";UNTIL={}", until_str));
}
if let Some(ref weekdays) = self.by_weekday {
if !weekdays.is_empty() {
let days: Vec<String> =
weekdays.iter().map(|wd| format!("{:?}", wd).to_uppercase()).collect();
rrule_str.push_str(&format!(";BYDAY={}", days.join(",")));
}
}
Ok(format!("DTSTART:{}\nRRULE:{}", dtstart.format("%Y%m%dT%H%M%S"), rrule_str))
}
pub fn generate_occurrences(&self, start: DateTime<Tz>) -> Result<Vec<DateTime<Tz>>> {
if self.count.is_none() && self.until.is_none() {
return Err(crate::error::EventixError::RecurrenceError(
"generate_occurrences() requires a bounded recurrence (set count or until). \
Use occurrences() for lazy iteration or generate_occurrences_capped() for \
a hard cap."
.to_string(),
));
}
Ok(self.occurrences(start).collect())
}
pub fn generate_occurrences_capped(
&self,
start: DateTime<Tz>,
max_occurrences: usize,
) -> Result<Vec<DateTime<Tz>>> {
Ok(self.occurrences(start).take(max_occurrences).collect())
}
pub fn occurrences(&self, start: DateTime<Tz>) -> OccurrenceIterator {
OccurrenceIterator::new(self.clone(), start)
}
}
fn advance_by_frequency(
current: DateTime<Tz>,
frequency: Frequency,
interval: u16,
intended_time: chrono::NaiveTime,
) -> Option<DateTime<Tz>> {
if interval == 0 {
return None;
}
let tz = current.timezone();
match frequency {
Frequency::Daily => {
let new_date = current.date_naive() + chrono::Days::new(interval as u64);
let naive = chrono::NaiveDateTime::new(new_date, intended_time);
resolve_local(tz, naive)
}
Frequency::Weekly => {
let new_date = current.date_naive() + chrono::Days::new(interval as u64 * 7);
let naive = chrono::NaiveDateTime::new(new_date, intended_time);
resolve_local(tz, naive)
}
Frequency::Monthly => {
let months_to_add = interval as i32;
let mut new_month = current.month() as i32 + months_to_add;
let mut new_year = current.year();
while new_month > 12 {
new_month -= 12;
new_year += 1;
}
let date = clamp_day_to_month(new_year, new_month as u32, current.day())?;
let naive = chrono::NaiveDateTime::new(date, intended_time);
resolve_local(tz, naive)
}
Frequency::Yearly => {
let new_year = current.year() + interval as i32;
let date = clamp_day_to_month(new_year, current.month(), current.day())?;
let naive = chrono::NaiveDateTime::new(date, intended_time);
resolve_local(tz, naive)
}
Frequency::Hourly => Some(current + chrono::Duration::hours(interval as i64)),
Frequency::Minutely => Some(current + chrono::Duration::minutes(interval as i64)),
Frequency::Secondly => Some(current + chrono::Duration::seconds(interval as i64)),
}
}
fn clamp_day_to_month(year: i32, month: u32, day: u32) -> Option<chrono::NaiveDate> {
if let Some(d) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
return Some(d);
}
let mut d = day.min(31);
while d > 28 {
d -= 1;
if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, d) {
return Some(date);
}
}
chrono::NaiveDate::from_ymd_opt(year, month, 28)
}
fn advance_weekly_weekday(
current: DateTime<Tz>,
interval: u16,
weekdays: &[chrono::Weekday],
intended_time: chrono::NaiveTime,
) -> Option<DateTime<Tz>> {
if interval == 0 {
return None;
}
let tz = current.timezone();
let date = current.date_naive();
let current_dow = date.weekday().num_days_from_monday();
for day_offset in 1u64..(7 - current_dow as u64) {
let candidate = date + chrono::Days::new(day_offset);
if weekdays.contains(&candidate.weekday()) {
let naive = chrono::NaiveDateTime::new(candidate, intended_time);
return resolve_local(tz, naive);
}
}
let week_start = date - chrono::Days::new(current_dow as u64);
let next_week_start = week_start + chrono::Days::new(interval as u64 * 7);
for day_offset in 0u64..7 {
let candidate = next_week_start + chrono::Days::new(day_offset);
if weekdays.contains(&candidate.weekday()) {
let naive = chrono::NaiveDateTime::new(candidate, intended_time);
return resolve_local(tz, naive);
}
}
None
}
fn advance_daily_weekday(
current: DateTime<Tz>,
interval: u16,
weekdays: &[chrono::Weekday],
intended_time: chrono::NaiveTime,
) -> Option<DateTime<Tz>> {
if interval == 0 {
return None;
}
let tz = current.timezone();
let mut date = current.date_naive();
for _ in 0..7 {
date = date + chrono::Days::new(interval as u64);
if weekdays.contains(&date.weekday()) {
let naive = chrono::NaiveDateTime::new(date, intended_time);
return resolve_local(tz, naive);
}
}
None
}
fn skip_subdaily_to_matching_day(
current: DateTime<Tz>,
frequency: Frequency,
interval: u16,
weekdays: &[chrono::Weekday],
) -> Option<DateTime<Tz>> {
if weekdays.contains(¤t.weekday()) {
return Some(current);
}
let tz = current.timezone();
let mut target_date = current.date_naive();
let mut found = false;
for _ in 0..7 {
target_date = target_date.succ_opt()?;
if weekdays.contains(&target_date.weekday()) {
found = true;
break;
}
}
if !found {
return None;
}
let midnight_time = chrono::NaiveTime::from_hms_opt(0, 0, 0)?;
let midnight = chrono::NaiveDateTime::new(target_date, midnight_time);
let target_dt = resolve_local(tz, midnight)?;
let gap_secs = target_dt.signed_duration_since(current).num_seconds();
debug_assert!(gap_secs > 0, "next matching midnight must be after current");
let interval_secs = match frequency {
Frequency::Hourly => interval as i64 * 3600,
Frequency::Minutely => interval as i64 * 60,
Frequency::Secondly => interval as i64,
_ => return None,
};
let steps = (gap_secs + interval_secs - 1) / interval_secs;
Some(current + chrono::Duration::seconds(steps * interval_secs))
}
fn expand_weekdays_in_month(
year: i32,
month: u32,
weekdays: &[chrono::Weekday],
tz: Tz,
time: chrono::NaiveTime,
) -> Vec<DateTime<Tz>> {
let mut results = Vec::with_capacity(5 * weekdays.len());
let Some(first) = chrono::NaiveDate::from_ymd_opt(year, month, 1) else {
return results;
};
let last = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
}
.and_then(|d| d.checked_sub_days(chrono::Days::new(1)))
.unwrap_or(first);
let mut date = first;
loop {
if weekdays.contains(&date.weekday()) {
let naive = chrono::NaiveDateTime::new(date, time);
if let Some(dt) = resolve_local(tz, naive) {
results.push(dt);
}
}
if date >= last {
break;
}
match date.succ_opt() {
Some(d) => date = d,
None => break,
}
}
results
}
fn expand_weekdays_in_year(
year: i32,
weekdays: &[chrono::Weekday],
tz: Tz,
time: chrono::NaiveTime,
) -> Vec<DateTime<Tz>> {
let mut results = Vec::with_capacity(53 * weekdays.len());
for month in 1..=12u32 {
results.extend(expand_weekdays_in_month(year, month, weekdays, tz, time));
}
results
}
#[derive(Debug, Clone)]
pub struct OccurrenceIterator {
recurrence: Recurrence,
current: DateTime<Tz>,
intended_time: chrono::NaiveTime,
count: u32,
exhausted: bool,
pending_byday: std::collections::VecDeque<DateTime<Tz>>,
byday_next_year: i32,
byday_next_month: u32,
byday_first: bool,
}
impl OccurrenceIterator {
fn new(recurrence: Recurrence, start: DateTime<Tz>) -> Self {
Self {
byday_next_year: start.year(),
byday_next_month: start.month(),
byday_first: true,
pending_byday: std::collections::VecDeque::new(),
recurrence,
intended_time: start.time(),
current: start,
count: 0,
exhausted: false,
}
}
fn is_exhausted(&self) -> bool {
if self.exhausted {
return true;
}
if let Some(max_count) = self.recurrence.count {
if self.count >= max_count {
return true;
}
}
if let Some(until) = self.recurrence.until {
if self.current > until {
return true;
}
}
false
}
fn compute_next(&self) -> Option<DateTime<Tz>> {
advance_by_frequency(
self.current,
self.recurrence.frequency,
self.recurrence.interval,
self.intended_time,
)
}
fn uses_byday_expansion(&self) -> bool {
matches!(self.recurrence.frequency, Frequency::Monthly | Frequency::Yearly)
&& self.recurrence.by_weekday.is_some()
}
fn next_byday_expanded(&mut self) -> Option<DateTime<Tz>> {
loop {
if let Some(dt) = self.pending_byday.pop_front() {
if let Some(max) = self.recurrence.count {
if self.count >= max {
return None;
}
}
if let Some(until) = self.recurrence.until {
if dt > until {
return None;
}
}
self.count += 1;
return Some(dt);
}
if self.exhausted {
return None;
}
self.expand_next_byday_period();
}
}
fn expand_next_byday_period(&mut self) {
let weekdays = match &self.recurrence.by_weekday {
Some(wd) => wd.clone(),
None => {
self.exhausted = true;
return;
}
};
let tz = self.current.timezone();
let is_first = self.byday_first;
let dates = match self.recurrence.frequency {
Frequency::Monthly => expand_weekdays_in_month(
self.byday_next_year,
self.byday_next_month,
&weekdays,
tz,
self.intended_time,
),
Frequency::Yearly => {
expand_weekdays_in_year(self.byday_next_year, &weekdays, tz, self.intended_time)
}
_ => {
self.exhausted = true;
return;
}
};
for dt in dates {
if is_first && dt < self.current {
continue;
}
self.pending_byday.push_back(dt);
}
self.byday_first = false;
if self.recurrence.frequency == Frequency::Monthly {
let total = (self.byday_next_year as i64) * 12
+ (self.byday_next_month as i64 - 1)
+ self.recurrence.interval as i64;
self.byday_next_year = (total / 12) as i32;
self.byday_next_month = (total % 12 + 1) as u32;
} else {
self.byday_next_year += self.recurrence.interval as i32;
}
if self.byday_next_year > 9999 {
self.exhausted = true;
}
}
}
impl Iterator for OccurrenceIterator {
type Item = DateTime<Tz>;
fn next(&mut self) -> Option<Self::Item> {
if self.recurrence.by_weekday.is_none() {
if self.is_exhausted() {
return None;
}
let result = self.current;
match self.compute_next() {
Some(next) => self.current = next,
None => self.exhausted = true,
}
self.count += 1;
return Some(result);
}
if self.uses_byday_expansion() {
return self.next_byday_expanded();
}
let weekdays = self.recurrence.by_weekday.as_ref()?;
loop {
if self.is_exhausted() {
return None;
}
let result = self.current;
match self.recurrence.frequency {
Frequency::Weekly => {
match advance_weekly_weekday(
result,
self.recurrence.interval,
weekdays,
self.intended_time,
) {
Some(next) => self.current = next,
None => self.exhausted = true,
}
if weekdays.contains(&result.weekday()) {
self.count += 1;
return Some(result);
}
}
Frequency::Daily => {
match advance_daily_weekday(
result,
self.recurrence.interval,
weekdays,
self.intended_time,
) {
Some(next) => self.current = next,
None => self.exhausted = true,
}
if weekdays.contains(&result.weekday()) {
self.count += 1;
return Some(result);
}
}
Frequency::Hourly | Frequency::Minutely | Frequency::Secondly => {
match self.compute_next() {
Some(next) => self.current = next,
None => self.exhausted = true,
}
if !weekdays.contains(&result.weekday()) {
match skip_subdaily_to_matching_day(
self.current,
self.recurrence.frequency,
self.recurrence.interval,
weekdays,
) {
Some(next) => self.current = next,
None => self.exhausted = true,
}
} else {
self.count += 1;
return Some(result);
}
}
_ => {
match self.compute_next() {
Some(next) => self.current = next,
None => self.exhausted = true,
}
self.count += 1;
return Some(result);
}
}
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
if let Some(max_count) = self.recurrence.count {
let remaining = max_count.saturating_sub(self.count) as usize;
(0, Some(remaining))
} else if self.recurrence.until.is_some() {
(0, None)
} else {
(0, None)
}
}
}
#[derive(Debug, Clone)]
pub struct RecurrenceFilter {
skip_weekends: bool,
skip_dates: Vec<DateTime<Tz>>,
}
impl RecurrenceFilter {
pub fn new() -> Self {
Self {
skip_weekends: false,
skip_dates: Vec::new(),
}
}
pub fn skip_weekends(mut self, skip: bool) -> Self {
self.skip_weekends = skip;
self
}
pub fn skip_dates(mut self, dates: Vec<DateTime<Tz>>) -> Self {
self.skip_dates.extend(dates);
self
}
pub fn should_skip(&self, date: &DateTime<Tz>) -> bool {
if self.skip_weekends {
let weekday = date.weekday();
if weekday == chrono::Weekday::Sat || weekday == chrono::Weekday::Sun {
return true;
}
}
self.skip_dates
.iter()
.any(|skip_date| skip_date.date_naive() == date.date_naive())
}
pub fn filter_occurrences(&self, occurrences: Vec<DateTime<Tz>>) -> Vec<DateTime<Tz>> {
occurrences.into_iter().filter(|dt| !self.should_skip(dt)).collect()
}
}
impl Default for RecurrenceFilter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::timezone::parse_timezone;
use chrono::Timelike;
#[test]
fn test_daily_recurrence() {
let recurrence = Recurrence::daily().count(5);
assert_eq!(recurrence.frequency(), Frequency::Daily);
assert_eq!(recurrence.get_count(), Some(5));
}
#[test]
fn test_weekly_recurrence() {
let recurrence = Recurrence::weekly().interval(2).count(10);
assert_eq!(recurrence.frequency(), Frequency::Weekly);
assert_eq!(recurrence.get_interval(), 2);
assert_eq!(recurrence.get_count(), Some(10));
}
#[test]
fn test_recurrence_filter_weekends() {
let filter = RecurrenceFilter::new().skip_weekends(true);
let tz = parse_timezone("UTC").unwrap();
let saturday = crate::timezone::parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap(); let monday = crate::timezone::parse_datetime_with_tz("2025-11-03 10:00:00", tz).unwrap();
assert!(filter.should_skip(&saturday));
assert!(!filter.should_skip(&monday));
}
#[test]
fn test_lazy_iterator_equivalence() {
let recurrence = Recurrence::daily().count(10);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let eager: Vec<_> = recurrence.generate_occurrences(start).unwrap();
let lazy: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(eager.len(), lazy.len());
for (e, l) in eager.iter().zip(lazy.iter()) {
assert_eq!(e, l);
}
}
#[test]
fn test_lazy_iterator_take() {
let recurrence = Recurrence::daily().count(100);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-03-15 14:00:00", tz).unwrap();
let first_5: Vec<_> = recurrence.occurrences(start).take(5).collect();
assert_eq!(first_5.len(), 5);
assert_eq!(first_5[0], start);
}
#[test]
fn test_lazy_iterator_nth() {
let recurrence = Recurrence::weekly().count(52);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 12:00:00", tz).unwrap();
let tenth = recurrence.occurrences(start).nth(9);
assert!(tenth.is_some());
let expected = start + chrono::Duration::weeks(9);
assert_eq!(tenth.unwrap(), expected);
}
#[test]
fn test_lazy_iterator_size_hint() {
let recurrence = Recurrence::daily().count(30);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 08:00:00", tz).unwrap();
let iter = recurrence.occurrences(start);
let (min, max) = iter.size_hint();
assert_eq!(min, 0);
assert_eq!(max, Some(30));
}
#[test]
fn test_lazy_iterator_monthly() {
let recurrence = Recurrence::monthly().count(6);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-15 10:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 6);
for (i, occ) in occurrences.iter().enumerate() {
assert_eq!(occ.month(), (1 + i) as u32);
}
}
#[test]
fn test_lazy_iterator_yearly() {
let recurrence = Recurrence::yearly().count(5);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-07-04 00:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 5);
for (i, occ) in occurrences.iter().enumerate() {
assert_eq!(occ.year(), 2025 + i as i32);
}
}
#[test]
fn test_lazy_iterator_until() {
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-01-05 09:00:00", tz).unwrap();
let recurrence = Recurrence::daily().until(end);
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 5);
assert_eq!(occurrences.last().unwrap(), &end);
}
#[test]
fn test_lazy_iterator_size_hint_until() {
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let end = crate::timezone::parse_datetime_with_tz("2025-12-31 09:00:00", tz).unwrap();
let recurrence = Recurrence::daily().until(end);
let iter = recurrence.occurrences(start);
let (min, max) = iter.size_hint();
assert_eq!(min, 0);
assert_eq!(max, None);
}
#[test]
fn test_lazy_iterator_size_hint_infinite() {
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let recurrence = Recurrence::daily();
let iter = recurrence.occurrences(start);
let (min, max) = iter.size_hint();
assert_eq!(min, 0);
assert_eq!(max, None);
}
#[test]
fn test_lazy_iterator_with_interval() {
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 10:00:00", tz).unwrap();
let recurrence = Recurrence::weekly().interval(2).count(4);
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 4);
for i in 1..occurrences.len() {
let diff = occurrences[i] - occurrences[i - 1];
assert_eq!(diff, chrono::Duration::weeks(2));
}
}
#[test]
fn test_monthly_day_clamping() {
let recurrence = Recurrence::monthly().count(4);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-31 12:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 4);
assert_eq!(occurrences[0].day(), 31); assert_eq!(occurrences[1].day(), 28); assert_eq!(occurrences[1].month(), 2);
assert_eq!(occurrences[2].month(), 3);
assert_eq!(occurrences[3].month(), 4);
}
#[test]
fn test_yearly_leap_day_clamping() {
let recurrence = Recurrence::yearly().count(3);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2024-02-29 08:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 3);
assert_eq!(occurrences[0].day(), 29); assert_eq!(occurrences[1].day(), 28); assert_eq!(occurrences[1].year(), 2025);
assert_eq!(occurrences[2].day(), 28); }
#[test]
fn test_zero_interval_clamped_to_one() {
let recurrence = Recurrence::daily().interval(0).count(10);
assert_eq!(recurrence.get_interval(), 1);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 10);
assert_eq!(occurrences[0], start);
}
#[test]
fn test_zero_interval_weekly_weekdays_clamped_to_one() {
let recurrence = Recurrence::weekly()
.interval(0)
.weekdays(vec![chrono::Weekday::Mon, chrono::Weekday::Wed])
.count(5);
assert_eq!(recurrence.get_interval(), 1);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 5);
assert_eq!(occurrences[0], start);
}
#[test]
fn test_weekdays_filter_lazy() {
use rrule::Weekday;
let recurrence = Recurrence::daily()
.weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri])
.count(14);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
for occ in &occurrences {
let wd = occ.weekday();
assert!(
wd == Weekday::Mon || wd == Weekday::Wed || wd == Weekday::Fri,
"unexpected weekday: {:?}",
wd
);
}
assert_eq!(occurrences.len(), 14);
}
#[test]
fn test_weekdays_filter_eager() {
use rrule::Weekday;
let recurrence = Recurrence::daily()
.weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri])
.count(14);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap();
let eager = recurrence.generate_occurrences(start).unwrap();
let lazy: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(eager.len(), lazy.len());
for (e, l) in eager.iter().zip(lazy.iter()) {
assert_eq!(e, l);
}
}
#[test]
fn test_weekly_weekdays_expansion_lazy() {
use rrule::Weekday;
let recurrence = Recurrence::weekly().weekdays(vec![Weekday::Mon, Weekday::Wed]).count(6);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 6);
assert_eq!(occurrences[0].day(), 6); assert_eq!(occurrences[1].day(), 8); assert_eq!(occurrences[2].day(), 13); assert_eq!(occurrences[3].day(), 15); assert_eq!(occurrences[4].day(), 20); assert_eq!(occurrences[5].day(), 22);
for occ in &occurrences {
let wd = occ.weekday();
assert!(wd == Weekday::Mon || wd == Weekday::Wed);
}
}
#[test]
fn test_weekly_weekdays_expansion_eager() {
use rrule::Weekday;
let recurrence = Recurrence::weekly().weekdays(vec![Weekday::Mon, Weekday::Wed]).count(6);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-06 09:00:00", tz).unwrap();
let eager = recurrence.generate_occurrences(start).unwrap();
let lazy: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(eager.len(), lazy.len());
for (e, l) in eager.iter().zip(lazy.iter()) {
assert_eq!(e, l);
}
}
#[test]
fn test_weekly_weekdays_biweekly() {
use rrule::Weekday;
let recurrence = Recurrence::weekly()
.interval(2)
.weekdays(vec![Weekday::Tue, Weekday::Thu])
.count(4);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-07 10:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 4);
assert_eq!(occurrences[0].day(), 7);
assert_eq!(occurrences[1].day(), 9);
assert_eq!(occurrences[2].day(), 21);
assert_eq!(occurrences[3].day(), 23);
}
#[test]
fn test_weekly_weekdays_start_not_in_weekdays() {
use rrule::Weekday;
let recurrence = Recurrence::weekly().weekdays(vec![Weekday::Mon, Weekday::Fri]).count(4);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-07 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 4);
for occ in &occurrences {
let wd = occ.weekday();
assert!(wd == Weekday::Mon || wd == Weekday::Fri);
}
}
#[test]
fn test_dst_spring_forward_daily() {
let recurrence = Recurrence::daily().count(3);
let tz = parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-03-08 02:30:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 3);
assert_eq!(occurrences[0].day(), 8);
assert_eq!(occurrences[1].day(), 9); assert_eq!(occurrences[2].day(), 10);
assert_eq!(occurrences[1].hour(), 3);
assert_eq!(occurrences[1].minute(), 30);
assert_eq!(occurrences[2].hour(), 2);
assert_eq!(occurrences[2].minute(), 30);
}
#[test]
fn test_dst_spring_forward_weekly() {
let recurrence = Recurrence::weekly().count(3);
let tz = parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-03-02 02:30:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 3);
assert_eq!(occurrences[0].day(), 2);
assert_eq!(occurrences[1].day(), 9);
assert_eq!(occurrences[2].day(), 16);
assert_eq!(occurrences[1].hour(), 3);
assert_eq!(occurrences[1].minute(), 30);
assert_eq!(occurrences[2].hour(), 2);
}
#[test]
fn test_dst_fall_back_daily() {
let recurrence = Recurrence::daily().count(3);
let tz = parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-11-01 01:30:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 3);
assert_eq!(occurrences[0].day(), 1);
assert_eq!(occurrences[1].day(), 2); assert_eq!(occurrences[2].day(), 3);
for occ in &occurrences {
assert_eq!(occ.hour(), 1);
assert_eq!(occ.minute(), 30);
}
}
#[test]
fn test_dst_spring_forward_eager_matches_lazy() {
let recurrence = Recurrence::daily().count(5);
let tz = parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-03-07 02:30:00", tz).unwrap();
let eager = recurrence.generate_occurrences(start).unwrap();
let lazy: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(eager.len(), lazy.len());
for (e, l) in eager.iter().zip(lazy.iter()) {
assert_eq!(e, l);
}
}
#[test]
fn test_hourly_recurrence() {
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 08:00:00", tz).unwrap();
let occs: Vec<_> = Recurrence::hourly().count(4).occurrences(start).collect();
assert_eq!(occs.len(), 4);
for i in 1..occs.len() {
assert_eq!(occs[i] - occs[i - 1], chrono::Duration::hours(1));
}
}
#[test]
fn test_minutely_recurrence() {
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 09:00:00", tz).unwrap();
let occs: Vec<_> =
Recurrence::minutely().interval(15).count(5).occurrences(start).collect();
assert_eq!(occs.len(), 5);
for i in 1..occs.len() {
assert_eq!(occs[i] - occs[i - 1], chrono::Duration::minutes(15));
}
}
#[test]
fn test_secondly_recurrence() {
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 10:00:00", tz).unwrap();
let occs: Vec<_> =
Recurrence::secondly().interval(30).count(6).occurrences(start).collect();
assert_eq!(occs.len(), 6);
for i in 1..occs.len() {
assert_eq!(occs[i] - occs[i - 1], chrono::Duration::seconds(30));
}
}
#[test]
fn test_hourly_across_dst_spring_forward() {
let tz = parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-03-09 01:00:00", tz).unwrap();
let occs: Vec<_> = Recurrence::hourly().count(4).occurrences(start).collect();
assert_eq!(occs.len(), 4);
for i in 1..occs.len() {
assert_eq!(occs[i] - occs[i - 1], chrono::Duration::hours(1));
}
assert_eq!(occs[1].hour(), 3);
}
#[test]
fn test_subdaily_new_constructor() {
let _ = Recurrence::new(Frequency::Hourly);
let _ = Recurrence::new(Frequency::Minutely);
let _ = Recurrence::new(Frequency::Secondly);
}
#[test]
fn test_generate_occurrences_capped() {
let recurrence = Recurrence::daily().count(30);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let capped = recurrence.generate_occurrences_capped(start, 5).unwrap();
assert_eq!(capped.len(), 5);
assert_eq!(capped[0], start);
}
#[test]
fn test_until_rrule_string_uses_utc() {
let tz = parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-01 10:00:00", tz).unwrap();
let until = crate::timezone::parse_datetime_with_tz("2025-06-30 10:00:00", tz).unwrap();
let recurrence = Recurrence::daily().until(until);
let rrule_str = recurrence.to_rrule_string(start).unwrap();
assert!(
rrule_str.contains("UNTIL=20250630T140000Z"),
"UNTIL should be converted to UTC, got: {}",
rrule_str
);
}
#[test]
fn test_generate_occurrences_rejects_unbounded() {
let recurrence = Recurrence::daily(); let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let result = recurrence.generate_occurrences(start);
assert!(result.is_err(), "unbounded recurrence should be rejected");
}
#[test]
fn test_interval_zero_rrule_string_consistent() {
let recurrence = Recurrence::daily().interval(0).count(5);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let rrule_str = recurrence.to_rrule_string(start).unwrap();
assert!(
!rrule_str.contains("INTERVAL=0"),
"interval(0) should not appear in RRULE, got: {}",
rrule_str
);
let r1 = Recurrence::daily().interval(1).count(5);
let rrule_str1 = r1.to_rrule_string(start).unwrap();
assert_eq!(rrule_str, rrule_str1);
}
#[test]
fn test_empty_weekdays_normalized_to_none() {
let recurrence = Recurrence::daily().weekdays(vec![]).count(5);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 5);
let rrule_str = recurrence.to_rrule_string(start).unwrap();
assert!(
!rrule_str.contains("BYDAY"),
"empty weekdays should not emit BYDAY, got: {}",
rrule_str
);
}
#[test]
fn test_monthly_byday_expansion() {
use chrono::Weekday;
let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue]).count(10);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 10);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Tue, "expected Tuesday, got {:?}", occ);
}
assert_eq!(occurrences[0].day(), 7);
assert_eq!(occurrences[1].day(), 14);
assert_eq!(occurrences[2].day(), 21);
assert_eq!(occurrences[3].day(), 28);
assert_eq!(occurrences[4].month(), 2);
assert_eq!(occurrences[4].day(), 4);
}
#[test]
fn test_monthly_byday_multiple_weekdays() {
use chrono::Weekday;
let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue, Weekday::Thu]).count(12);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 12);
for occ in &occurrences {
let wd = occ.weekday();
assert!(
wd == Weekday::Tue || wd == Weekday::Thu,
"expected Tue or Thu, got {:?} on {}",
wd,
occ
);
}
assert_eq!(occurrences[0].day(), 2);
assert_eq!(occurrences[0].weekday(), Weekday::Thu);
assert_eq!(occurrences[1].day(), 7);
assert_eq!(occurrences[1].weekday(), Weekday::Tue);
}
#[test]
fn test_monthly_byday_with_interval() {
use chrono::Weekday;
let recurrence = Recurrence::monthly().interval(2).weekdays(vec![Weekday::Mon]).count(10);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 10);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Mon);
}
assert_eq!(occurrences[0].month(), 1);
assert_eq!(occurrences[0].day(), 6);
assert_eq!(occurrences[3].month(), 1);
assert_eq!(occurrences[3].day(), 27);
assert_eq!(occurrences[4].month(), 3);
assert_eq!(occurrences[4].day(), 3);
}
#[test]
fn test_monthly_byday_start_on_matching_weekday() {
use chrono::Weekday;
let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue]).count(5);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-07 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 5);
assert_eq!(occurrences[0], start);
assert_eq!(occurrences[1].day(), 14);
assert_eq!(occurrences[4].month(), 2);
assert_eq!(occurrences[4].day(), 4);
}
#[test]
fn test_monthly_byday_with_until() {
use chrono::Weekday;
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let until = crate::timezone::parse_datetime_with_tz("2025-01-20 23:59:59", tz).unwrap();
let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Tue]).until(until);
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 2);
assert_eq!(occurrences[0].day(), 7);
assert_eq!(occurrences[1].day(), 14);
}
#[test]
fn test_yearly_byday_expansion() {
use chrono::Weekday;
let recurrence = Recurrence::yearly().weekdays(vec![Weekday::Mon]).count(5);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 5);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Mon);
}
assert_eq!(occurrences[0].day(), 6);
assert_eq!(occurrences[0].month(), 1);
assert_eq!(occurrences[1].day(), 13);
}
#[test]
fn test_yearly_byday_with_interval() {
use chrono::Weekday;
let recurrence = Recurrence::yearly().interval(2).weekdays(vec![Weekday::Fri]).count(3);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 3);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Fri);
}
assert_eq!(occurrences[0].year(), 2025);
assert_eq!(occurrences[0].month(), 1);
assert_eq!(occurrences[0].day(), 3);
}
#[test]
fn test_monthly_byday_eager_matches_lazy() {
use chrono::Weekday;
let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Wed, Weekday::Fri]).count(15);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let lazy: Vec<_> = recurrence.clone().occurrences(start).collect();
let eager = recurrence.generate_occurrences(start).unwrap();
assert_eq!(lazy.len(), 15);
assert_eq!(lazy, eager);
}
#[test]
fn test_monthly_byday_dst_spring_forward() {
use chrono::Weekday;
let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Sun]).count(12);
let tz = parse_timezone("America/New_York").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-03-01 02:30:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 12);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Sun);
}
let mar_9 = occurrences.iter().find(|o| o.month() == 3 && o.day() == 9);
assert!(mar_9.is_some(), "Mar 9 (spring forward) should be present");
}
#[test]
fn test_daily_weekday_direct_jump() {
use chrono::Weekday;
let recurrence = Recurrence::daily()
.weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri])
.count(9);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-04 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 9);
for occ in &occurrences {
let wd = occ.weekday();
assert!(
wd == Weekday::Mon || wd == Weekday::Wed || wd == Weekday::Fri,
"expected Mon/Wed/Fri, got {:?} on {}",
wd,
occ
);
}
assert_eq!(occurrences[0].day(), 6);
assert_eq!(occurrences[0].weekday(), Weekday::Mon);
}
#[test]
fn test_daily_interval2_weekday_jump() {
use chrono::Weekday;
let recurrence = Recurrence::daily().interval(2).weekdays(vec![Weekday::Tue]).count(5);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 5);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Tue);
}
}
#[test]
fn test_daily_weekday_eager_matches_lazy() {
use chrono::Weekday;
let recurrence = Recurrence::daily()
.weekdays(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri])
.count(15);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let lazy: Vec<_> = recurrence.clone().occurrences(start).collect();
let eager = recurrence.generate_occurrences(start).unwrap();
assert_eq!(lazy.len(), 15);
assert_eq!(lazy, eager);
}
#[test]
fn test_subdaily_weekday_skip() {
use chrono::Weekday;
let recurrence = Recurrence::hourly().interval(1).weekdays(vec![Weekday::Mon]).count(48);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-06 00:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 48);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Mon, "expected Monday, got {:?}", occ);
}
assert_eq!(occurrences[0].day(), 6);
assert_eq!(occurrences[23].day(), 6);
assert_eq!(occurrences[23].hour(), 23);
assert_eq!(occurrences[24].day(), 13);
assert_eq!(occurrences[24].hour(), 0);
}
#[test]
fn test_subdaily_weekday_minutely_skip() {
use chrono::Weekday;
let recurrence = Recurrence::minutely()
.interval(30)
.weekdays(vec![Weekday::Tue, Weekday::Thu])
.count(96);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-07 00:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 96);
for occ in &occurrences {
let wd = occ.weekday();
assert!(wd == Weekday::Tue || wd == Weekday::Thu, "expected Tue/Thu, got {:?}", wd);
}
assert_eq!(occurrences[0].day(), 7);
assert_eq!(occurrences[47].day(), 7);
assert_eq!(occurrences[48].day(), 9);
}
#[test]
fn test_subdaily_weekday_secondly_perf() {
use chrono::Weekday;
let recurrence = Recurrence::secondly().interval(1).weekdays(vec![Weekday::Wed]).count(10);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-02 00:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 10);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Wed);
}
assert_eq!(occurrences[0].day(), 8);
assert_eq!(occurrences[0].month(), 1);
}
#[test]
fn test_subdaily_weekday_start_on_matching_day() {
use chrono::Weekday;
let recurrence = Recurrence::hourly().interval(4).weekdays(vec![Weekday::Wed]).count(6);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-08 08:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 6);
assert_eq!(occurrences[0], start);
for occ in &occurrences {
assert_eq!(occ.weekday(), Weekday::Wed);
}
}
#[test]
fn test_byday_monthly_start_past_last_weekday() {
use chrono::Weekday;
let recurrence = Recurrence::monthly().weekdays(vec![Weekday::Mon]).count(2);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-31 10:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 2);
assert_eq!(occurrences[0].day(), 3);
assert_eq!(occurrences[0].month(), 2);
assert_eq!(occurrences[0].weekday(), Weekday::Mon);
assert_eq!(occurrences[1].day(), 10);
assert_eq!(occurrences[1].month(), 2);
}
#[test]
fn test_byday_yearly_start_past_all_weekdays() {
use chrono::Weekday;
let recurrence = Recurrence::yearly().weekdays(vec![Weekday::Mon]).count(1);
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-12-31 10:00:00", tz).unwrap();
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 1);
assert_eq!(occurrences[0].year(), 2026);
assert_eq!(occurrences[0].month(), 1);
assert_eq!(occurrences[0].day(), 5);
}
#[test]
fn test_subdaily_skip_does_not_overshoot_matching_day() {
use chrono::Datelike;
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-06-07 23:00:00", tz).unwrap();
assert_eq!(start.weekday(), chrono::Weekday::Sat);
let recurrence = Recurrence::hourly()
.interval(1)
.weekdays(vec![chrono::Weekday::Sun, chrono::Weekday::Mon])
.count(3);
let occurrences: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occurrences.len(), 3);
assert_eq!(occurrences[0].weekday(), chrono::Weekday::Sun);
assert_eq!(occurrences[0].day(), 8);
assert_eq!(occurrences[0].hour(), 0);
assert_eq!(occurrences[1].hour(), 1);
assert_eq!(occurrences[2].hour(), 2);
}
#[test]
fn test_uses_byday_expansion_false() {
let recurrence = Recurrence::monthly().count(1);
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let occs: Vec<_> = recurrence.occurrences(start).collect();
assert_eq!(occs.len(), 1);
}
#[test]
fn test_private_recurrence_helper_guards_and_edges() {
use chrono::{Datelike, Weekday};
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let intended = start.time();
let recurrence = Recurrence {
frequency: Frequency::Weekly,
interval: 1,
count: Some(2),
until: None,
by_weekday: Some(vec![]),
};
let rrule = recurrence.to_rrule_string(start).unwrap();
assert!(!rrule.contains("BYDAY"));
assert!(advance_by_frequency(start, Frequency::Daily, 0, intended).is_none());
let monthly = advance_by_frequency(start, Frequency::Monthly, 14, intended).unwrap();
assert_eq!(monthly.year(), 2026);
assert_eq!(monthly.month(), 3);
assert!(clamp_day_to_month(2025, 13, 31).is_none());
assert!(advance_weekly_weekday(start, 0, &[Weekday::Mon], intended).is_none());
assert!(advance_weekly_weekday(start, 1, &[], intended).is_none());
assert!(advance_daily_weekday(start, 0, &[Weekday::Mon], intended).is_none());
assert!(advance_daily_weekday(start, 7, &[Weekday::Tue], intended).is_none());
assert!(skip_subdaily_to_matching_day(start, Frequency::Hourly, 1, &[]).is_none());
assert!(
skip_subdaily_to_matching_day(start, Frequency::Daily, 1, &[Weekday::Thu]).is_none()
);
assert!(expand_weekdays_in_month(2025, 13, &[Weekday::Mon], tz, intended).is_empty());
}
#[test]
fn test_private_occurrence_iterator_exhaustion_paths() {
use chrono::Weekday;
let tz = parse_timezone("UTC").unwrap();
let start = crate::timezone::parse_datetime_with_tz("2025-01-01 09:00:00", tz).unwrap();
let mut exhausted_iter =
Recurrence::monthly().weekdays(vec![Weekday::Mon]).count(1).occurrences(start);
exhausted_iter.exhausted = true;
assert!(exhausted_iter.is_exhausted());
assert!(exhausted_iter.next_byday_expanded().is_none());
let mut no_byday = Recurrence::daily().count(1).occurrences(start);
no_byday.expand_next_byday_period();
assert!(no_byday.exhausted);
let mut wrong_freq = Recurrence::daily().count(1).occurrences(start);
wrong_freq.recurrence.by_weekday = Some(vec![Weekday::Mon]);
wrong_freq.expand_next_byday_period();
assert!(wrong_freq.exhausted);
let mut high_year =
Recurrence::yearly().weekdays(vec![Weekday::Mon]).count(1).occurrences(start);
high_year.byday_next_year = 10_000;
high_year.expand_next_byday_period();
assert!(high_year.exhausted);
let mut no_next = Recurrence::daily().count(2).occurrences(start);
no_next.recurrence.interval = 0;
assert_eq!(no_next.next(), Some(start));
assert!(no_next.exhausted);
}
}