use crate::helpers::fix_timezone;
use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
use chrono_tz::Tz;
use rrule::RRuleSet;
const MIN_OCCURRENCES: usize = 3;
const MAX_RANGE_EXTENSION_DAYS: i64 = 10 * 365;
pub fn between_datetime_f64(
dt_start_utc: f64,
tzid: &str,
recurrence: &str,
start_datetime: f64,
end_datetime: f64,
extend_end: bool,
include_dtstart: bool,
) -> Vec<f64> {
between_datetime_i64(
dt_start_utc as i64,
tzid,
recurrence,
start_datetime as i64,
end_datetime as i64,
extend_end,
include_dtstart,
)
.into_iter()
.map(|x| x as f64)
.collect()
}
pub fn between_datetime_i64(
dt_start_utc: i64,
tzid: &str,
recurrence: &str,
start_datetime: i64,
end_datetime: i64,
extend_end: bool,
include_dtstart: bool,
) -> Vec<i64> {
let fixed_tz: String = fix_timezone(tzid, Some("UTC"));
let dtstart = get_dtstart_from_epoch(dt_start_utc, Some(&fixed_tz));
let rruleset = match (dtstart.clone() + "\n" + recurrence).parse::<RRuleSet>() {
Ok(rr) => rr,
Err(err1) => {
let fixed_recurrence = fix_datetime_recurrence(recurrence, dt_start_utc, &fixed_tz);
eprintln!("Error parsing recurrence rule: {}", err1);
match (dtstart + "\n" + &fixed_recurrence).parse::<RRuleSet>() {
Ok(rr) => rr,
Err(err2) => {
eprintln!(
"Error parsing recurrence rule even after attempting fixing: {}",
err2
);
return Vec::new();
}
}
}
};
let max_end = end_datetime + MAX_RANGE_EXTENSION_DAYS * 24 * 60 * 60 * 1000;
let mut arr = vec![];
let iterator = rruleset.into_iter();
for date in iterator {
let date_timestamp = date.timestamp_millis();
if date_timestamp >= start_datetime && date_timestamp <= end_datetime {
arr.push(date_timestamp);
} else if date_timestamp > end_datetime {
if extend_end && arr.len() < MIN_OCCURRENCES && date_timestamp <= max_end {
arr.push(date_timestamp);
} else {
break;
}
}
}
if include_dtstart
&& dt_start_utc >= start_datetime
&& dt_start_utc <= end_datetime
&& !arr.contains(&dt_start_utc)
&& !is_datetime_excluded(recurrence, dt_start_utc, &fixed_tz)
{
arr.insert(0, dt_start_utc);
}
arr
}
pub fn between_date_f64(
dt_start_date: &str,
recurrence: &str,
start_date: &str,
end_date: &str,
extend_end: bool,
include_dtstart: bool,
) -> Vec<String> {
between_date_i64(
dt_start_date,
recurrence,
start_date,
end_date,
extend_end,
include_dtstart,
)
}
pub fn between_date_i64(
dt_start_date: &str,
recurrence: &str,
start_date: &str,
end_date: &str,
extend_end: bool,
include_dtstart: bool,
) -> Vec<String> {
let start_naive = match chrono::NaiveDate::parse_from_str(start_date, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let end_naive = match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let dtstart = get_dtstart_from_date(dt_start_date);
let fixed_recurrence = fix_date_recurrence(recurrence);
let rruleset = match (dtstart + "\n" + &fixed_recurrence).parse::<RRuleSet>() {
Ok(rr) => rr,
Err(err2) => {
eprintln!(
"Error parsing recurrence rule even after attempting fixing: {}",
err2
);
return Vec::new();
}
};
let max_end_naive = end_naive + chrono::Duration::days(MAX_RANGE_EXTENSION_DAYS);
let mut arr: Vec<String> = vec![];
let iterator = rruleset.into_iter().peekable();
for date in iterator {
let date_naive = date.date_naive();
if date_naive >= start_naive && date_naive <= end_naive {
arr.push(date.format("%F").to_string());
} else if date_naive > end_naive {
if extend_end && arr.len() < MIN_OCCURRENCES && date_naive <= max_end_naive {
arr.push(date.format("%F").to_string());
} else {
break;
}
}
}
if include_dtstart {
if let Ok(d) = chrono::NaiveDate::parse_from_str(dt_start_date, "%Y-%m-%d") {
if d >= start_naive
&& d <= end_naive
&& !arr.iter().any(|s| s == dt_start_date)
&& !is_date_excluded(recurrence, dt_start_date)
{
arr.insert(0, dt_start_date.to_string());
}
}
}
arr
}
pub fn get_dtstart_from_epoch(epoch_ms: i64, tz: Option<&str>) -> String {
let dt_utc = DateTime::<Utc>::from_timestamp_millis(epoch_ms).unwrap();
if let Some(tzid) = tz.filter(|s| !s.is_empty()) {
if let Ok(zone) = tzid.parse::<Tz>() {
let local = dt_utc.with_timezone(&zone);
return format!("DTSTART;TZID={}:{}", tzid, local.format("%Y%m%dT%H%M%S"));
}
}
format!("DTSTART:{}Z", dt_utc.format("%Y%m%dT%H%M%S"))
}
pub fn get_dtstart_from_date(date: &str) -> String {
let stamp = date.replace("-", "");
format!("DTSTART;VALUE=DATE:{}", stamp)
}
pub fn fix_datetime_recurrence_until(recurrence: &str, start_epoch: i64, timezone: &str) -> String {
let tz: Tz = timezone.parse().unwrap_or(chrono_tz::UTC);
let until_key = "UNTIL=";
let Some(pos) = recurrence.find(until_key) else {
return recurrence.to_string();
};
let val_start = pos + until_key.len();
let rest = &recurrence[val_start..];
let val_len = rest.find(';').unwrap_or(rest.len());
let until_val = &rest[..val_len];
if until_val.ends_with('Z') {
return recurrence.to_string();
}
let is_date_only = until_val.len() == 8 && until_val.chars().all(|c| c.is_ascii_digit());
if is_date_only {
let start_utc = match Utc.timestamp_opt(start_epoch, 0).single() {
Some(dt) => dt,
None => return recurrence.to_string(),
};
let start_local = start_utc.with_timezone(&tz);
let (hh, mm, ss) = (
start_local.hour(),
start_local.minute(),
start_local.second(),
);
let (y, m, d) = match parse_yyyymmdd(until_val) {
Some(v) => v,
None => return recurrence.to_string(),
};
let until_local = match tz.with_ymd_and_hms(y, m, d, hh, mm, ss).earliest() {
Some(dt) => dt,
None => return recurrence.to_string(),
};
let until_utc = until_local.with_timezone(&Utc);
let until_fmt = until_utc.format("%Y%m%dT%H%M%SZ").to_string();
return splice_value(recurrence, val_start, val_len, &until_fmt);
}
let is_local_datetime = until_val.len() == 15
&& until_val.as_bytes()[8] == b'T'
&& until_val
.chars()
.enumerate()
.all(|(i, c)| i == 8 || c.is_ascii_digit());
if is_local_datetime {
let (y, m, d) = match parse_yyyymmdd(&until_val[0..8]) {
Some(v) => v,
None => return recurrence.to_string(),
};
let (hh, mm, ss) = match parse_hhmmss(&until_val[9..15]) {
Some(v) => v,
None => return recurrence.to_string(),
};
let until_local = match tz.with_ymd_and_hms(y, m, d, hh, mm, ss).earliest() {
Some(dt) => dt,
None => return recurrence.to_string(),
};
let until_utc = until_local.with_timezone(&Utc);
let until_fmt = until_utc.format("%Y%m%dT%H%M%SZ").to_string();
return splice_value(recurrence, val_start, val_len, &until_fmt);
}
recurrence.to_string()
}
pub fn fix_recurrence_split(recurrence: &str) -> String {
let mut s = recurrence.to_string();
for key in ["RRULE", "EXDATE", "RDATE", "DTSTART"] {
let needle = format!(";{}", key);
let replacement = format!("\n{}", key);
s = s.replace(&needle, &replacement);
}
s
}
pub fn fix_datetime_recurrence(recurrence: &str, start_epoch: i64, timezone: &str) -> String {
let split = fix_recurrence_split(recurrence);
fix_datetime_recurrence_until(&split, start_epoch, timezone)
}
pub fn fix_date_recurrence_until(recurrence: &str) -> String {
let until_key = "UNTIL=";
let Some(pos) = recurrence.find(until_key) else {
return recurrence.to_string();
};
let val_start = pos + until_key.len();
let rest = &recurrence[val_start..];
let val_len = rest.find(';').unwrap_or(rest.len());
let until_val = &rest[..val_len];
let is_date_only = until_val.len() == 8 && until_val.chars().all(|c| c.is_ascii_digit());
if is_date_only {
return recurrence.to_string();
}
let is_datetime = (until_val.len() == 15
|| (until_val.len() == 16 && until_val.ends_with('Z')))
&& until_val.as_bytes()[8] == b'T'
&& until_val
.chars()
.enumerate()
.all(|(i, c)| i == 8 || i == 15 && c == 'Z' || c.is_ascii_digit());
if is_datetime {
let date_only = &until_val[0..8];
if parse_yyyymmdd(date_only).is_none() {
return recurrence.to_string();
}
return splice_value(recurrence, val_start, val_len, date_only);
}
recurrence.to_string()
}
pub fn fix_date_recurrence(recurrence: &str) -> String {
let split = fix_recurrence_split(recurrence);
fix_date_recurrence_until(&split)
}
fn parse_yyyymmdd(s: &str) -> Option<(i32, u32, u32)> {
if s.len() != 8 {
return None;
}
let y: i32 = s[0..4].parse().ok()?;
let m: u32 = s[4..6].parse().ok()?;
let d: u32 = s[6..8].parse().ok()?;
Some((y, m, d))
}
fn parse_hhmmss(s: &str) -> Option<(u32, u32, u32)> {
if s.len() != 6 {
return None;
}
let hh: u32 = s[0..2].parse().ok()?;
let mm: u32 = s[2..4].parse().ok()?;
let ss: u32 = s[4..6].parse().ok()?;
Some((hh, mm, ss))
}
fn splice_value(orig: &str, start: usize, len: usize, replacement: &str) -> String {
let mut out = String::with_capacity(orig.len() + replacement.len().saturating_sub(len));
out.push_str(&orig[..start]);
out.push_str(replacement);
out.push_str(&orig[start + len..]);
out
}
pub fn add_until_date(recurrence: &str, until_date: &str, use_exact_until: bool) -> String {
let until_value = if use_exact_until {
until_date.replace("-", "")
} else {
if let Ok(d) = chrono::NaiveDate::parse_from_str(until_date, "%Y-%m-%d") {
let prev = d.pred_opt().unwrap_or(d);
prev.format("%Y%m%d").to_string()
} else {
until_date.replace("-", "")
}
};
let lines: Vec<&str> = recurrence.lines().collect();
let mut result = Vec::with_capacity(lines.len());
for line in lines {
if line.starts_with("RRULE:") {
let mut rrule = line.to_string();
if let Some(count_pos) = rrule.find("COUNT=") {
let count_start = count_pos;
let count_rest = &rrule[count_pos..];
let count_end = count_pos + count_rest.find(';').unwrap_or(count_rest.len());
rrule = splice_value(
&rrule,
count_start,
count_end - count_start + if count_end < rrule.len() { 1 } else { 0 },
"",
);
}
if let Some(until_pos) = rrule.find("UNTIL=") {
let until_start = until_pos;
let until_rest = &rrule[until_pos..];
let until_end = until_pos + until_rest.find(';').unwrap_or(until_rest.len());
rrule = splice_value(
&rrule,
until_start,
until_end - until_start,
&format!("UNTIL={}", until_value),
);
} else {
if rrule.ends_with(';') {
rrule = format!("{}UNTIL={}", rrule, until_value);
} else {
rrule = format!("{};UNTIL={}", rrule, until_value);
}
}
result.push(rrule);
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
pub fn add_until_datetime(
recurrence: &str,
until_timestamp: f64,
use_exact_until: bool,
timezone: &str,
) -> String {
let until_dt_utc = if use_exact_until {
DateTime::<Utc>::from_timestamp_millis(until_timestamp as i64).unwrap()
} else {
let utc_dt = DateTime::<Utc>::from_timestamp_millis(until_timestamp as i64).unwrap();
let tz: chrono_tz::Tz = timezone.parse().unwrap_or(chrono_tz::UTC);
let local_dt = utc_dt.with_timezone(&tz);
let prev_day = local_dt
.date_naive()
.pred_opt()
.unwrap_or(local_dt.date_naive());
let local_235959 = tz
.with_ymd_and_hms(
prev_day.year(),
prev_day.month(),
prev_day.day(),
23,
59,
59,
)
.single()
.unwrap_or_else(|| {
tz.with_ymd_and_hms(
prev_day.year(),
prev_day.month(),
prev_day.day(),
23,
59,
59,
)
.earliest()
.unwrap()
});
local_235959.with_timezone(&Utc)
};
let until_value = until_dt_utc.format("%Y%m%dT%H%M%SZ").to_string();
let lines: Vec<&str> = recurrence.lines().collect();
let mut result = Vec::with_capacity(lines.len());
for line in lines {
if line.starts_with("RRULE:") {
let mut rrule = line.to_string();
if let Some(count_pos) = rrule.find("COUNT=") {
let count_start = count_pos;
let count_rest = &rrule[count_pos..];
let count_end = count_pos + count_rest.find(';').unwrap_or(count_rest.len());
rrule = splice_value(
&rrule,
count_start,
count_end - count_start + if count_end < rrule.len() { 1 } else { 0 },
"",
);
}
if let Some(until_pos) = rrule.find("UNTIL=") {
let until_start = until_pos;
let until_rest = &rrule[until_pos..];
let until_end = until_pos + until_rest.find(';').unwrap_or(until_rest.len());
rrule = splice_value(
&rrule,
until_start,
until_end - until_start,
&format!("UNTIL={}", until_value),
);
} else {
if rrule.ends_with(';') {
rrule = format!("{}UNTIL={}", rrule, until_value);
} else {
rrule = format!("{};UNTIL={}", rrule, until_value);
}
}
result.push(rrule);
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
pub fn add_exdate_datetime(recurrence: &str, exdate_ts: f64) -> String {
let dt_utc = match DateTime::<Utc>::from_timestamp_millis(exdate_ts as i64) {
Some(dt) => dt,
None => return recurrence.to_string(),
};
let value = dt_utc.format("%Y%m%dT%H%M%SZ").to_string();
let mut lines: Vec<String> = recurrence.lines().map(|s| s.to_string()).collect();
let mut last_exdate_idx: Option<usize> = None;
for (idx, line) in lines.iter().enumerate() {
if line.starts_with("EXDATE") {
last_exdate_idx = Some(idx);
}
}
if let Some(idx) = last_exdate_idx {
let line = &mut lines[idx];
if let Some(colon_pos) = line.find(':') {
let (head, tail) = line.split_at(colon_pos + 1); let existing_values: Vec<&str> = tail.split(',').collect();
if existing_values.iter().any(|v| *v == value) {
return recurrence.to_string();
}
let mut new_tail = tail.to_string();
if !new_tail.is_empty() {
new_tail.push(',');
}
new_tail.push_str(&value);
*line = format!("{}{}", head, new_tail);
} else {
*line = format!("{}:{}", line, value);
}
return lines.join("\n");
}
if recurrence.is_empty() {
format!("EXDATE:{}", value)
} else {
format!("{}\nEXDATE:{}", recurrence, value)
}
}
pub fn add_exdate_date(recurrence: &str, exdate_date: &str) -> String {
let naive = match chrono::NaiveDate::parse_from_str(exdate_date, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => return recurrence.to_string(),
};
let token = naive.format("%Y%m%d").to_string();
let mut lines: Vec<String> = recurrence.lines().map(|s| s.to_string()).collect();
let mut last_idx: Option<usize> = None;
for (idx, line) in lines.iter().enumerate() {
if line.starts_with("EXDATE;VALUE=DATE:") {
last_idx = Some(idx);
}
}
if let Some(idx) = last_idx {
let line = &mut lines[idx];
let prefix = "EXDATE;VALUE=DATE:";
let existing = &line[prefix.len()..];
let parts: Vec<&str> = existing.split(',').collect();
if parts.iter().any(|v| *v == token) {
return recurrence.to_string();
}
if existing.is_empty() {
*line = format!("{}{}", prefix, token);
} else {
*line = format!("{}{},{}", prefix, existing, token);
}
return lines.join("\n");
}
let new_line = format!("EXDATE;VALUE=DATE:{}", token);
if recurrence.is_empty() {
new_line
} else {
format!("{}\n{}", recurrence, new_line)
}
}
pub fn is_datetime_excluded(recurrence: &str, ts_utc_ms: i64, tzid: &str) -> bool {
let dt_utc = match DateTime::<Utc>::from_timestamp_millis(ts_utc_ms) {
Some(dt) => dt,
None => return false,
};
let tz: Tz = tzid.parse().unwrap_or(chrono_tz::UTC);
let utc_full = dt_utc.format("%Y%m%dT%H%M%SZ").to_string(); let utc_no_sec = dt_utc.format("%Y%m%dT%H%MZ").to_string();
for line in recurrence.lines() {
if !line.starts_with("EXDATE") || line.contains(";VALUE=DATE") {
continue;
}
let Some(colon_pos) = line.find(':') else {
continue; };
let head = &line[..colon_pos];
let values = &line[colon_pos + 1..];
let line_tz: Tz = head
.split(';')
.find_map(|p| p.strip_prefix("TZID="))
.and_then(|tz| tz.parse::<Tz>().ok())
.unwrap_or(tz);
let dt_local = dt_utc.with_timezone(&line_tz);
let local_full = dt_local.format("%Y%m%dT%H%M%S").to_string(); let local_no_sec = dt_local.format("%Y%m%dT%H%M").to_string();
for raw in values.split(',') {
let v = raw.trim();
if v.is_empty() {
continue;
}
if v.ends_with('Z') {
if v == utc_full || v == utc_no_sec {
return true;
}
} else {
if v == local_full || v == local_no_sec {
return true;
}
}
}
}
false
}
pub fn is_date_excluded(recurrence: &str, date_str: &str) -> bool {
let naive = match chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => return false,
};
let token = naive.format("%Y%m%d").to_string();
for line in recurrence.lines() {
if line.starts_with("EXDATE;VALUE=DATE:") {
let values = &line["EXDATE;VALUE=DATE:".len()..];
if values.split(',').any(|v| v.trim() == token) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests1 {
use super::*;
use chrono::TimeZone;
#[test]
fn test_add_until_to_rrule_all_day() {
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR";
let result = add_until_date(recurrence, "2025-09-30", true);
assert_eq!(result, "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250930");
let recurrence = "RRULE:FREQ=WEEKLY;UNTIL=20250101;BYDAY=MO,WE,FR";
let result = add_until_date(recurrence, "2025-09-30", true);
assert_eq!(result, "RRULE:FREQ=WEEKLY;UNTIL=20250930;BYDAY=MO,WE,FR");
let recurrence = "RRULE:FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE,FR";
let result = add_until_date(recurrence, "2025-09-30", true);
assert_eq!(result, "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250930");
let recurrence = "DTSTART;VALUE=DATE:20250101\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\nEXDATE;VALUE=DATE:20250115";
let result = add_until_date(recurrence, "2025-09-30", true);
assert_eq!(
result,
"DTSTART;VALUE=DATE:20250101\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250930\nEXDATE;VALUE=DATE:20250115"
);
}
#[test]
fn test_add_until_to_rrule_all_day_adjusted() {
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR";
let result = add_until_date(recurrence, "2025-09-30", false);
assert_eq!(result, "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250929");
let recurrence = "RRULE:FREQ=WEEKLY;UNTIL=20250101;BYDAY=MO,WE,FR";
let result = add_until_date(recurrence, "2025-09-30", false);
assert_eq!(result, "RRULE:FREQ=WEEKLY;UNTIL=20250929;BYDAY=MO,WE,FR");
let recurrence = "RRULE:FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE,FR";
let result = add_until_date(recurrence, "2025-09-30", false);
assert_eq!(result, "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250929");
let recurrence = "DTSTART;VALUE=DATE:20250101\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\nEXDATE;VALUE=DATE:20250115";
let result = add_until_date(recurrence, "2025-09-30", false);
assert_eq!(
result,
"DTSTART;VALUE=DATE:20250101\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250929\nEXDATE;VALUE=DATE:20250115"
);
}
#[test]
fn test_add_until_to_rrule_datetime() {
let timestamp = Utc
.with_ymd_and_hms(2025, 9, 30, 15, 30, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR";
let result = add_until_datetime(recurrence, timestamp, true, "UTC");
assert_eq!(
result,
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250930T153000Z"
);
let recurrence = "RRULE:FREQ=WEEKLY;UNTIL=20250101T000000Z;BYDAY=MO,WE,FR";
let result = add_until_datetime(recurrence, timestamp, true, "UTC");
assert_eq!(
result,
"RRULE:FREQ=WEEKLY;UNTIL=20250930T153000Z;BYDAY=MO,WE,FR"
);
let recurrence = "RRULE:FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE,FR";
let result = add_until_datetime(recurrence, timestamp, true, "UTC");
assert_eq!(
result,
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250930T153000Z"
);
let recurrence =
"DTSTART:20250101T120000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\nEXDATE:20250115T120000Z";
let result = add_until_datetime(recurrence, timestamp, true, "UTC");
assert_eq!(
result,
"DTSTART:20250101T120000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250930T153000Z\nEXDATE:20250115T120000Z"
);
}
#[test]
fn add_until_datetime_ends_before_when_not_exact() {
let timestamp = Utc
.with_ymd_and_hms(2025, 9, 30, 15, 30, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let recurrence = "RRULE:FREQ=DAILY";
let result = add_until_datetime(recurrence, timestamp, false, "UTC");
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=20250929T235959Z");
}
#[test]
fn add_until_datetime_saturates_at_epoch_zero() {
let timestamp = 500.0f64;
let recurrence = "RRULE:FREQ=DAILY";
let result = add_until_datetime(recurrence, timestamp, false, "UTC");
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=19691231T235959Z");
}
#[test]
fn add_until_date_first_of_month_subtracts_to_prev_month_end() {
let recurrence = "RRULE:FREQ=DAILY";
let result = add_until_date(recurrence, "2025-10-01", false);
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=20250930");
let result2 = add_until_date(recurrence, "2025-01-01", false);
assert_eq!(result2, "RRULE:FREQ=DAILY;UNTIL=20241231");
}
#[test]
fn add_until_date_first_of_month_replaces_existing_until_correctly() {
let recurrence = "RRULE:FREQ=DAILY;UNTIL=20250115";
let result = add_until_date(recurrence, "2025-10-01", false);
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=20250930");
}
#[test]
fn fixes_date_only_until_using_dtstart_wall_clock() {
let start_utc = Utc
.with_ymd_and_hms(2025, 5, 20, 22, 0, 0)
.single()
.unwrap();
let start_epoch = start_utc.timestamp();
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617;BYDAY=TU";
let fixed = fix_datetime_recurrence(input, start_epoch, "America/Los_Angeles");
assert_eq!(fixed, "RRULE:FREQ=WEEKLY;UNTIL=20250617T220000Z;BYDAY=TU");
}
#[test]
fn fixes_local_datetime_until_without_z() {
let start_utc = Utc
.with_ymd_and_hms(2025, 5, 20, 22, 0, 0)
.single()
.unwrap();
let start_epoch = start_utc.timestamp();
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617T235959;BYDAY=TU";
let fixed = fix_datetime_recurrence(input, start_epoch, "America/Los_Angeles");
assert_eq!(fixed, "RRULE:FREQ=WEEKLY;UNTIL=20250618T065959Z;BYDAY=TU");
}
#[test]
fn leaves_utc_until_with_z_unchanged() {
let start_utc = Utc
.with_ymd_and_hms(2025, 5, 20, 22, 0, 0)
.single()
.unwrap();
let start_epoch = start_utc.timestamp();
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617T220000Z;BYDAY=TU";
let fixed = fix_datetime_recurrence(input, start_epoch, "America/Los_Angeles");
assert_eq!(fixed, input);
}
#[test]
fn add_until_datetime_timezone_conversion_example() {
let timestamp = Utc
.with_ymd_and_hms(2025, 9, 10, 14, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let recurrence = "RRULE:FREQ=DAILY";
let result = add_until_datetime(recurrence, timestamp, false, "Europe/Madrid");
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=20250909T215959Z");
}
#[test]
fn add_until_datetime_exact_ignores_timezone() {
let timestamp = Utc
.with_ymd_and_hms(2025, 9, 10, 14, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let recurrence = "RRULE:FREQ=DAILY";
let result = add_until_datetime(recurrence, timestamp, true, "Europe/Madrid");
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=20250910T140000Z");
}
#[test]
fn add_until_datetime_east_of_utc_tokyo() {
let timestamp = Utc
.with_ymd_and_hms(2025, 9, 10, 14, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let recurrence = "RRULE:FREQ=DAILY";
let result = add_until_datetime(recurrence, timestamp, false, "Asia/Tokyo");
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=20250909T145959Z");
}
#[test]
fn add_until_datetime_west_of_utc_los_angeles_winter() {
let timestamp = Utc
.with_ymd_and_hms(2025, 1, 10, 12, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let recurrence = "RRULE:FREQ=DAILY";
let result = add_until_datetime(recurrence, timestamp, false, "America/Los_Angeles");
assert_eq!(result, "RRULE:FREQ=DAILY;UNTIL=20250110T075959Z");
}
}
#[cfg(test)]
mod tests3 {
use super::fix_date_recurrence;
#[test]
fn removes_time_from_datetime_with_z() {
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617T220000Z;BYDAY=TU";
let fixed = fix_date_recurrence(input);
assert_eq!(fixed, "RRULE:FREQ=WEEKLY;UNTIL=20250617;BYDAY=TU");
}
#[test]
fn removes_time_from_datetime_without_z() {
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617T235959;BYDAY=TU";
let fixed = fix_date_recurrence(input);
assert_eq!(fixed, "RRULE:FREQ=WEEKLY;UNTIL=20250617;BYDAY=TU");
}
#[test]
fn leaves_date_only_unchanged() {
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617;BYDAY=TU";
let fixed = fix_date_recurrence(input);
assert_eq!(fixed, input);
}
#[test]
fn leaves_no_until_unchanged() {
let input = "RRULE:FREQ=WEEKLY;BYDAY=TU";
let fixed = fix_date_recurrence(input);
assert_eq!(fixed, input);
}
#[test]
fn handles_until_at_end_of_string() {
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617T220000Z";
let fixed = fix_date_recurrence(input);
assert_eq!(fixed, "RRULE:FREQ=WEEKLY;UNTIL=20250617");
}
#[test]
fn handles_until_in_middle_of_string() {
let input = "RRULE:FREQ=WEEKLY;UNTIL=20250617T220000Z;BYDAY=TU;COUNT=10";
let fixed = fix_date_recurrence(input);
assert_eq!(fixed, "RRULE:FREQ=WEEKLY;UNTIL=20250617;BYDAY=TU;COUNT=10");
}
}
#[cfg(test)]
mod tests4a {
use super::{fix_datetime_recurrence, fix_recurrence_split};
use chrono::TimeZone;
use chrono::Utc;
#[test]
fn splits_semicolon_separated_clauses_datetime() {
let input = "EXDATE;TZID=Europe/Rome:20250402T163000,20250416T163000,20250430T163000,20250507T163000,20250604T163000,20250625T163000,20250709T163000,20250716T163000,20250820T163000;RRULE:FREQ=WEEKLY;BYDAY=WE";
let expected = "EXDATE;TZID=Europe/Rome:20250402T163000,20250416T163000,20250430T163000,20250507T163000,20250604T163000,20250625T163000,20250709T163000,20250716T163000,20250820T163000\nRRULE:FREQ=WEEKLY;BYDAY=WE";
assert_eq!(fix_recurrence_split(input), expected);
}
#[test]
fn wrapper_calls_split_then_until() {
let start_epoch = Utc
.with_ymd_and_hms(2025, 5, 20, 22, 0, 0)
.single()
.unwrap()
.timestamp();
let input =
"EXDATE;TZID=Europe/Rome:20250402T163000;RRULE:FREQ=WEEKLY;UNTIL=20250617;BYDAY=TU";
let out = fix_datetime_recurrence(input, start_epoch, "America/Los_Angeles");
assert_eq!(
out,
"EXDATE;TZID=Europe/Rome:20250402T163000\nRRULE:FREQ=WEEKLY;UNTIL=20250617T220000Z;BYDAY=TU"
);
}
#[test]
fn splits_semicolon_separated_clauses_date() {
let input = "EXDATE;VALUE=DATE:20250402,20250416;RRULE:FREQ=WEEKLY;BYDAY=WE";
let expected = "EXDATE;VALUE=DATE:20250402,20250416\nRRULE:FREQ=WEEKLY;BYDAY=WE";
assert_eq!(fix_recurrence_split(input), expected);
}
}
#[cfg(test)]
mod tests_exdate {
use super::*;
use chrono::TimeZone;
#[test]
fn add_exdate_adds_new_line_when_missing() {
let recurrence = "RRULE:FREQ=DAILY";
let ts = Utc
.with_ymd_and_hms(2025, 1, 10, 12, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime(recurrence, ts);
assert_eq!(out, "RRULE:FREQ=DAILY\nEXDATE:20250110T120000Z");
}
#[test]
fn add_exdate_appends_to_existing_line() {
let recurrence = "RRULE:FREQ=DAILY\nEXDATE:20250110T120000Z";
let ts2 = Utc
.with_ymd_and_hms(2025, 1, 11, 9, 30, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime(recurrence, ts2);
assert_eq!(
out,
"RRULE:FREQ=DAILY\nEXDATE:20250110T120000Z,20250111T093000Z"
);
}
#[test]
fn add_exdate_does_not_duplicate_if_already_present() {
let recurrence = "RRULE:FREQ=DAILY\nEXDATE:20250110T120000Z";
let ts = Utc
.with_ymd_and_hms(2025, 1, 10, 12, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime(recurrence, ts);
assert_eq!(out, recurrence);
}
#[test]
fn add_exdate_respects_existing_params() {
let recurrence = "EXDATE;TZID=Europe/Rome:20250110T120000";
let ts2 = Utc
.with_ymd_and_hms(2025, 1, 11, 9, 30, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime(recurrence, ts2);
assert_eq!(
out,
"EXDATE;TZID=Europe/Rome:20250110T120000,20250111T093000Z"
);
}
#[test]
fn add_exdate_handles_empty_recurrence() {
let ts = Utc
.with_ymd_and_hms(2025, 2, 1, 8, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime("", ts);
assert_eq!(out, "EXDATE:20250201T080000Z");
}
#[test]
fn add_exdate_uses_last_exdate_line() {
let recurrence = "EXDATE:20250110T120000Z\nRRULE:FREQ=DAILY\nEXDATE:20250111T090000Z";
let ts = Utc
.with_ymd_and_hms(2025, 1, 12, 7, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime(recurrence, ts);
assert_eq!(
out,
"EXDATE:20250110T120000Z\nRRULE:FREQ=DAILY\nEXDATE:20250111T090000Z,20250112T070000Z"
);
}
#[test]
fn add_exdate_skips_when_timestamp_invalid() {
let bad_ts = i64::MAX as f64;
let recurrence = "RRULE:FREQ=DAILY";
let out = add_exdate_datetime(recurrence, bad_ts);
assert_eq!(out, recurrence);
}
#[test]
fn add_exdate_handles_malformed_exdate_without_colon() {
let recurrence = "RRULE:FREQ=DAILY\nEXDATE";
let ts = Utc
.with_ymd_and_hms(2025, 3, 5, 10, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime(recurrence, ts);
assert_eq!(out, "RRULE:FREQ=DAILY\nEXDATE:20250305T100000Z");
}
#[test]
fn add_exdate_does_not_confuse_suffix_match() {
let recurrence = "EXDATE:20250110T120000Z,20250110T120000";
let ts = Utc
.with_ymd_and_hms(2025, 1, 10, 12, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let out = add_exdate_datetime(recurrence, ts);
assert_eq!(out, recurrence);
}
#[test]
fn add_exdate_datetime_ignores_recurrence_line_order() {
let ts = Utc
.with_ymd_and_hms(2025, 4, 1, 9, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let rec_a = "RRULE:FREQ=DAILY\nEXDATE:20250331T090000Z";
let rec_b = "EXDATE:20250331T090000Z\nRRULE:FREQ=DAILY";
let out_a = add_exdate_datetime(rec_a, ts);
let out_b = add_exdate_datetime(rec_b, ts);
assert_eq!(
out_a,
"RRULE:FREQ=DAILY\nEXDATE:20250331T090000Z,20250401T090000Z"
);
assert_eq!(
out_b,
"EXDATE:20250331T090000Z,20250401T090000Z\nRRULE:FREQ=DAILY"
);
}
#[test]
fn add_exdate_datetime_uses_last_exdate_regardless_of_other_order() {
let ts = Utc
.with_ymd_and_hms(2025, 4, 2, 9, 0, 0)
.single()
.unwrap()
.timestamp_millis() as f64;
let recurrence = "DTSTART:20250301T090000Z\nEXDATE:20250310T090000Z\nRRULE:FREQ=DAILY\nEXDATE:20250320T090000Z";
let out = add_exdate_datetime(recurrence, ts);
assert_eq!(
out,
"DTSTART:20250301T090000Z\nEXDATE:20250310T090000Z\nRRULE:FREQ=DAILY\nEXDATE:20250320T090000Z,20250402T090000Z"
);
}
#[test]
fn add_exdate_date_adds_new_line_when_missing() {
let recurrence = "RRULE:FREQ=DAILY";
let out = add_exdate_date(recurrence, "2025-01-10");
assert_eq!(out, "RRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250110");
}
#[test]
fn add_exdate_date_appends_to_existing_value_date_line() {
let recurrence = "RRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250110";
let out = add_exdate_date(recurrence, "2025-01-11");
assert_eq!(out, "RRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250110,20250111");
}
#[test]
fn add_exdate_date_does_not_duplicate_existing_date_token() {
let recurrence = "EXDATE;VALUE=DATE:20250110,20250111";
let out = add_exdate_date(recurrence, "2025-01-10");
assert_eq!(out, recurrence);
}
#[test]
fn add_exdate_date_uses_last_value_date_line() {
let recurrence = "EXDATE;VALUE=DATE:20250101\nRRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250110";
let out = add_exdate_date(recurrence, "2025-01-15");
assert_eq!(
out,
"EXDATE;VALUE=DATE:20250101\nRRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250110,20250115"
);
}
#[test]
fn add_exdate_date_handles_empty_recurrence() {
let out = add_exdate_date("", "2025-03-05");
assert_eq!(out, "EXDATE;VALUE=DATE:20250305");
}
#[test]
fn add_exdate_date_invalid_date_returns_unchanged() {
let recurrence = "RRULE:FREQ=DAILY";
let out = add_exdate_date(recurrence, "not-a-date");
assert_eq!(out, recurrence);
}
#[test]
fn add_exdate_date_ignores_recurrence_line_order() {
let rec_a = "RRULE:FREQ=WEEKLY\nEXDATE;VALUE=DATE:20250331";
let rec_b = "EXDATE;VALUE=DATE:20250331\nRRULE:FREQ=WEEKLY";
let out_a = add_exdate_date(rec_a, "2025-04-01");
let out_b = add_exdate_date(rec_b, "2025-04-01");
assert_eq!(
out_a,
"RRULE:FREQ=WEEKLY\nEXDATE;VALUE=DATE:20250331,20250401"
);
assert_eq!(
out_b,
"EXDATE;VALUE=DATE:20250331,20250401\nRRULE:FREQ=WEEKLY"
);
}
#[test]
fn add_exdate_date_uses_last_value_date_line_among_others() {
let recurrence = "DTSTART;VALUE=DATE:20250301\nEXDATE;VALUE=DATE:20250305\nRRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250310";
let out = add_exdate_date(recurrence, "2025-03-15");
assert_eq!(
out,
"DTSTART;VALUE=DATE:20250301\nEXDATE;VALUE=DATE:20250305\nRRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250310,20250315"
);
}
}
#[cfg(test)]
mod tests_range_extension {
use super::*;
use chrono::TimeZone;
fn ts_ms(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> i64 {
Utc.with_ymd_and_hms(year, month, day, hour, min, sec)
.single()
.unwrap()
.timestamp_millis()
}
#[test]
fn extends_range_for_yearly_recurrence_datetime() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY",
start,
end,
true,
false,
);
assert!(
result.len() >= 3,
"Expected at least 3 occurrences, got {}",
result.len()
);
assert_eq!(result[0], ts_ms(2025, 6, 15, 10, 0, 0));
}
#[test]
fn extends_range_for_yearly_recurrence_date() {
let result = between_date_i64(
"2020-06-15",
"RRULE:FREQ=YEARLY",
"2025-01-01",
"2025-01-31",
true,
false,
);
assert!(
result.len() >= 3,
"Expected at least 3 occurrences, got {}",
result.len()
);
assert_eq!(result[0], "2025-06-15");
}
#[test]
fn extends_range_for_every_49_months_datetime() {
let dt_start = ts_ms(2020, 1, 15, 9, 0, 0);
let start = ts_ms(2024, 1, 1, 0, 0, 0);
let end = ts_ms(2024, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY;INTERVAL=49",
start,
end,
true,
false,
);
assert!(
!result.is_empty(),
"Expected to find occurrences for every-49-months rule"
);
}
#[test]
fn extends_range_for_every_49_months_date() {
let result = between_date_i64(
"2020-01-15",
"RRULE:FREQ=MONTHLY;INTERVAL=49",
"2024-01-01",
"2024-01-31",
true,
false,
);
assert!(
!result.is_empty(),
"Expected to find occurrences for every-49-months rule"
);
}
#[test]
fn does_not_extend_range_when_until_present_datetime() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;UNTIL=20240101T000000Z",
start,
end,
true,
false,
);
assert!(
result.is_empty(),
"Should not extend range when UNTIL is present"
);
}
#[test]
fn does_not_extend_range_when_until_present_date() {
let result = between_date_i64(
"2020-06-15",
"RRULE:FREQ=YEARLY;UNTIL=20240101",
"2025-01-01",
"2025-01-31",
true,
false,
);
assert!(
result.is_empty(),
"Should not extend range when UNTIL is present"
);
}
#[test]
fn count_with_past_occurrences_returns_empty_datetime() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;COUNT=2",
start,
end,
true,
false,
);
assert!(
result.is_empty(),
"Expected empty since COUNT=2 occurrences are in the past"
);
}
#[test]
fn count_with_past_occurrences_returns_empty_date() {
let result = between_date_i64(
"2020-06-15",
"RRULE:FREQ=YEARLY;COUNT=2",
"2025-01-01",
"2025-01-31",
true,
false,
);
assert!(
result.is_empty(),
"Expected empty since COUNT=2 occurrences are in the past"
);
}
#[test]
fn extends_range_for_count_with_future_occurrences_datetime() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;COUNT=10",
start,
end,
true,
false,
);
assert!(
result.len() >= 3,
"Expected at least 3 occurrences for COUNT=10 rule with future occurrences, got {}",
result.len()
);
assert_eq!(result[0], ts_ms(2025, 6, 15, 10, 0, 0));
}
#[test]
fn extends_range_for_count_with_future_occurrences_date() {
let result = between_date_i64(
"2020-06-15",
"RRULE:FREQ=YEARLY;COUNT=10",
"2025-01-01",
"2025-01-31",
true,
false,
);
assert!(
result.len() >= 3,
"Expected at least 3 occurrences for COUNT=10 rule with future occurrences, got {}",
result.len()
);
assert_eq!(result[0], "2025-06-15");
}
#[test]
fn returns_occurrences_in_original_range_without_extension_if_enough() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 10, 23, 59, 59);
let result =
between_datetime_i64(dt_start, "UTC", "RRULE:FREQ=DAILY", start, end, true, false);
assert_eq!(result.len(), 10);
assert_eq!(result[0], ts_ms(2025, 1, 1, 10, 0, 0));
assert_eq!(result[9], ts_ms(2025, 1, 10, 10, 0, 0));
}
#[test]
fn extends_range_for_biennial_recurrence() {
let dt_start = ts_ms(2020, 3, 20, 14, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;INTERVAL=2",
start,
end,
true,
false,
);
assert!(
result.len() >= 3,
"Expected at least 3 occurrences, got {}",
result.len()
);
assert_eq!(result[0], ts_ms(2026, 3, 20, 14, 0, 0));
}
#[test]
fn extends_range_when_fewer_than_min_occurrences_found() {
let dt_start = ts_ms(2025, 1, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 2, 28, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY",
start,
end,
true,
false,
);
assert!(
result.len() >= 3,
"Expected at least 3 occurrences, got {}",
result.len()
);
}
#[test]
fn does_not_extend_when_extend_end_is_false() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY",
start,
end,
false,
false,
);
assert!(
result.is_empty(),
"Should not extend range when extend_end is false"
);
}
#[test]
fn does_not_extend_when_extend_end_is_false_date() {
let result = between_date_i64(
"2020-06-15",
"RRULE:FREQ=YEARLY",
"2025-01-01",
"2025-01-31",
false,
false,
);
assert!(
result.is_empty(),
"Should not extend range when extend_end is false"
);
}
#[test]
fn compare_extend_true_vs_false_yearly_datetime() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY",
start,
end,
true,
false,
);
assert!(
result_no_extend.is_empty(),
"Without extend: expected 0 occurrences, got {}",
result_no_extend.len()
);
assert!(
result_with_extend.len() >= 3,
"With extend: expected at least 3 occurrences, got {}",
result_with_extend.len()
);
assert_eq!(result_with_extend[0], ts_ms(2025, 6, 15, 10, 0, 0));
}
#[test]
fn compare_extend_true_vs_false_yearly_date() {
let result_no_extend = between_date_i64(
"2020-06-15",
"RRULE:FREQ=YEARLY",
"2025-01-01",
"2025-01-31",
false,
false,
);
let result_with_extend = between_date_i64(
"2020-06-15",
"RRULE:FREQ=YEARLY",
"2025-01-01",
"2025-01-31",
true,
false,
);
assert!(
result_no_extend.is_empty(),
"Without extend: expected 0 occurrences, got {}",
result_no_extend.len()
);
assert!(
result_with_extend.len() >= 3,
"With extend: expected at least 3 occurrences, got {}",
result_with_extend.len()
);
assert_eq!(result_with_extend[0], "2025-06-15");
}
#[test]
fn compare_extend_true_vs_false_monthly_datetime() {
let dt_start = ts_ms(2025, 1, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 2, 28, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY",
start,
end,
true,
false,
);
assert_eq!(
result_no_extend.len(),
2,
"Without extend: expected 2 occurrences, got {}",
result_no_extend.len()
);
assert!(
result_with_extend.len() >= 3,
"With extend: expected at least 3 occurrences, got {}",
result_with_extend.len()
);
assert_eq!(result_no_extend[0], result_with_extend[0]);
assert_eq!(result_no_extend[1], result_with_extend[1]);
}
#[test]
fn compare_extend_true_vs_false_biennial_datetime() {
let dt_start = ts_ms(2020, 3, 20, 14, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;INTERVAL=2",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;INTERVAL=2",
start,
end,
true,
false,
);
assert!(
result_no_extend.is_empty(),
"Without extend: expected 0 occurrences, got {}",
result_no_extend.len()
);
assert!(
result_with_extend.len() >= 3,
"With extend: expected at least 3 occurrences, got {}",
result_with_extend.len()
);
assert_eq!(result_with_extend[0], ts_ms(2026, 3, 20, 14, 0, 0));
}
#[test]
fn compare_extend_true_vs_false_every_49_months() {
let dt_start = ts_ms(2020, 1, 15, 9, 0, 0);
let start = ts_ms(2024, 1, 1, 0, 0, 0);
let end = ts_ms(2024, 1, 31, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY;INTERVAL=49",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY;INTERVAL=49",
start,
end,
true,
false,
);
assert!(
result_no_extend.is_empty(),
"Without extend: expected 0 occurrences, got {}",
result_no_extend.len()
);
assert!(
!result_with_extend.is_empty(),
"With extend: expected some occurrences for every-49-months rule"
);
}
#[test]
fn extend_has_no_effect_when_enough_occurrences_in_range() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 10, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
let result_with_extend =
between_datetime_i64(dt_start, "UTC", "RRULE:FREQ=DAILY", start, end, true, false);
assert_eq!(result_no_extend.len(), 10);
assert_eq!(result_with_extend.len(), 10);
assert_eq!(result_no_extend, result_with_extend);
}
#[test]
fn until_in_past_returns_empty_regardless_of_extend() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;UNTIL=20240101T000000Z",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;UNTIL=20240101T000000Z",
start,
end,
true,
false,
);
assert!(result_no_extend.is_empty());
assert!(result_with_extend.is_empty());
assert_eq!(result_no_extend, result_with_extend);
}
#[test]
fn until_in_future_extends_to_find_occurrences() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0); let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;UNTIL=20280101T000000Z",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;UNTIL=20280101T000000Z",
start,
end,
true,
false,
);
assert!(
result_no_extend.is_empty(),
"Without extend: expected 0 occurrences, got {}",
result_no_extend.len()
);
assert_eq!(
result_with_extend.len(),
3,
"With extend: expected 3 occurrences, got {}",
result_with_extend.len()
);
assert_eq!(result_with_extend[0], ts_ms(2025, 6, 15, 10, 0, 0));
assert_eq!(result_with_extend[1], ts_ms(2026, 6, 15, 10, 0, 0));
assert_eq!(result_with_extend[2], ts_ms(2027, 6, 15, 10, 0, 0));
}
#[test]
fn compare_extend_true_vs_false_count_with_future_occurrences() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;COUNT=10",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;COUNT=10",
start,
end,
true,
false,
);
assert!(
result_no_extend.is_empty(),
"Without extend: expected 0 occurrences, got {}",
result_no_extend.len()
);
assert!(
result_with_extend.len() >= 3,
"With extend: expected at least 3 occurrences, got {}",
result_with_extend.len()
);
}
#[test]
fn count_past_occurrences_same_result_with_or_without_extend() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result_no_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;COUNT=2",
start,
end,
false,
false,
);
let result_with_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY;COUNT=2",
start,
end,
true,
false,
);
assert!(result_no_extend.is_empty());
assert!(result_with_extend.is_empty());
assert_eq!(result_no_extend, result_with_extend);
}
}
#[cfg(test)]
mod tests_between_edge_cases {
use super::*;
fn ts_ms(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> i64 {
chrono::Utc
.with_ymd_and_hms(year, month, day, hour, min, sec)
.unwrap()
.timestamp_millis()
}
#[test]
fn datetime_empty_recurrence_returns_empty() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 12, 31, 23, 59, 59);
let result = between_datetime_i64(dt_start, "UTC", "", start, end, false, false);
assert!(result.is_empty());
}
#[test]
fn datetime_invalid_recurrence_returns_empty() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 12, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"INVALID_RRULE_STRING",
start,
end,
false,
false,
);
assert!(result.is_empty());
}
#[test]
fn datetime_start_equals_end_single_occurrence() {
let dt_start = ts_ms(2025, 6, 15, 10, 0, 0);
let exact_moment = ts_ms(2025, 6, 15, 10, 0, 0);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
exact_moment,
exact_moment,
false,
false,
);
assert_eq!(result.len(), 1);
assert_eq!(result[0], exact_moment);
}
#[test]
fn datetime_start_after_end_returns_empty() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 12, 31, 0, 0, 0);
let end = ts_ms(2025, 1, 1, 0, 0, 0);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
assert!(result.is_empty());
}
#[test]
fn datetime_dtstart_after_range_returns_empty() {
let dt_start = ts_ms(2030, 1, 1, 10, 0, 0); let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 12, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
assert!(result.is_empty());
}
#[test]
fn datetime_dtstart_before_range_finds_occurrences() {
let dt_start = ts_ms(2020, 1, 1, 10, 0, 0); let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 5, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
assert_eq!(result.len(), 5);
assert_eq!(result[0], ts_ms(2025, 1, 1, 10, 0, 0));
}
#[test]
fn datetime_weekly_correct_occurrences() {
let dt_start = ts_ms(2025, 1, 6, 9, 0, 0); let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=WEEKLY;BYDAY=MO",
start,
end,
false,
false,
);
assert_eq!(result.len(), 4);
assert_eq!(result[0], ts_ms(2025, 1, 6, 9, 0, 0));
assert_eq!(result[1], ts_ms(2025, 1, 13, 9, 0, 0));
assert_eq!(result[2], ts_ms(2025, 1, 20, 9, 0, 0));
assert_eq!(result[3], ts_ms(2025, 1, 27, 9, 0, 0));
}
#[test]
fn datetime_monthly_bymonthday() {
let dt_start = ts_ms(2025, 1, 15, 14, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 6, 30, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY;BYMONTHDAY=15",
start,
end,
false,
false,
);
assert_eq!(result.len(), 6);
assert_eq!(result[0], ts_ms(2025, 1, 15, 14, 0, 0));
assert_eq!(result[5], ts_ms(2025, 6, 15, 14, 0, 0));
}
#[test]
fn datetime_with_exdate_excludes_date() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 5, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY\nEXDATE:20250103T100000Z",
start,
end,
false,
false,
);
assert_eq!(result.len(), 4);
assert!(!result.contains(&ts_ms(2025, 1, 3, 10, 0, 0)));
}
#[test]
fn datetime_timezone_europe_london() {
let dt_start = ts_ms(2025, 6, 15, 9, 0, 0); let start = ts_ms(2025, 6, 1, 0, 0, 0);
let end = ts_ms(2025, 6, 30, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"Europe/London",
"RRULE:FREQ=WEEKLY;BYDAY=SU",
start,
end,
false,
false,
);
assert!(!result.is_empty(), "Should find some Sunday occurrences");
}
#[test]
fn datetime_count_limits_occurrences() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 12, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY;COUNT=5",
start,
end,
false,
false,
);
assert_eq!(result.len(), 5);
}
#[test]
fn datetime_interval_every_3_days() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 15, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY;INTERVAL=3",
start,
end,
false,
false,
);
assert_eq!(result.len(), 5);
assert_eq!(result[0], ts_ms(2025, 1, 1, 10, 0, 0));
assert_eq!(result[1], ts_ms(2025, 1, 4, 10, 0, 0));
assert_eq!(result[4], ts_ms(2025, 1, 13, 10, 0, 0));
}
#[test]
fn datetime_boundary_occurrence_at_start() {
let dt_start = ts_ms(2025, 1, 1, 0, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0); let end = ts_ms(2025, 1, 2, 0, 0, 0);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
assert!(result.contains(&ts_ms(2025, 1, 1, 0, 0, 0)));
}
#[test]
fn datetime_boundary_occurrence_at_end() {
let dt_start = ts_ms(2025, 1, 1, 0, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 2, 0, 0, 0);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
assert!(result.contains(&ts_ms(2025, 1, 2, 0, 0, 0)));
}
#[test]
fn datetime_with_exdate_and_timezone() {
let dt_start = ts_ms(2025, 12, 12, 6, 30, 0);
let start = ts_ms(2025, 12, 12, 0, 0, 0);
let end = ts_ms(2025, 12, 22, 0, 0, 0);
let result = between_datetime_i64(
dt_start,
"Europe/Bucharest",
"RRULE:FREQ=WEEKLY;BYDAY=SA\nEXDATE;TZID=Europe/Bucharest:20251212T083000",
start,
end,
false,
true,
);
let expected = vec![ts_ms(2025, 12, 13, 6, 30, 0), ts_ms(2025, 12, 20, 6, 30, 0)];
assert_eq!(result, expected);
}
#[test]
fn date_empty_recurrence_returns_empty() {
let result = between_date_i64("2025-01-01", "", "2025-01-01", "2025-12-31", false, false);
assert!(result.is_empty());
}
#[test]
fn date_invalid_recurrence_returns_empty() {
let result = between_date_i64(
"2025-01-01",
"INVALID_RRULE",
"2025-01-01",
"2025-12-31",
false,
false,
);
assert!(result.is_empty());
}
#[test]
fn date_invalid_dtstart_format_returns_empty() {
let result = between_date_i64(
"not-a-date",
"RRULE:FREQ=DAILY",
"2025-01-01",
"2025-12-31",
false,
false,
);
assert!(result.is_empty());
}
#[test]
fn date_daily_correct_format() {
let result = between_date_i64(
"2025-01-01",
"RRULE:FREQ=DAILY",
"2025-01-01",
"2025-01-10",
false,
false,
);
assert!(!result.is_empty(), "Should find daily occurrences");
for date in &result {
assert!(date.len() == 10, "Date should be in YYYY-MM-DD format");
assert!(
date.starts_with("2025-01-"),
"Date should be in January 2025"
);
}
}
#[test]
fn date_weekly_multiple_days() {
let result = between_date_i64(
"2025-01-01",
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE",
"2025-01-01",
"2025-01-31",
false,
false,
);
assert!(!result.is_empty(), "Should find Mon/Wed occurrences");
let has_monday = result.iter().any(|d| {
d == "2025-01-06" || d == "2025-01-13" || d == "2025-01-20" || d == "2025-01-27"
});
let has_wednesday = result.iter().any(|d| {
d == "2025-01-01"
|| d == "2025-01-08"
|| d == "2025-01-15"
|| d == "2025-01-22"
|| d == "2025-01-29"
});
assert!(
has_monday || has_wednesday,
"Should find at least some Mon or Wed"
);
}
#[test]
fn date_monthly_last_day() {
let result = between_date_i64(
"2025-01-31",
"RRULE:FREQ=MONTHLY;BYMONTHDAY=-1",
"2025-01-01",
"2025-06-30",
false,
false,
);
assert_eq!(result.len(), 6);
assert!(result.contains(&"2025-01-31".to_string()));
assert!(result.contains(&"2025-02-28".to_string()));
assert!(result.contains(&"2025-04-30".to_string()));
}
#[test]
fn date_yearly_leap_year() {
let result = between_date_i64(
"2024-02-29",
"RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29",
"2024-01-01",
"2028-12-31",
false,
false,
);
assert_eq!(result.len(), 2);
assert!(result.contains(&"2024-02-29".to_string()));
assert!(result.contains(&"2028-02-29".to_string()));
}
#[test]
fn date_with_exdate_excludes() {
let result = between_date_i64(
"2025-01-01",
"RRULE:FREQ=DAILY\nEXDATE;VALUE=DATE:20250105",
"2025-01-01",
"2025-01-10",
false,
false,
);
assert_eq!(result.len(), 9);
assert!(!result.contains(&"2025-01-05".to_string()));
}
#[test]
fn date_count_limits_occurrences() {
let result = between_date_i64(
"2025-01-01",
"RRULE:FREQ=WEEKLY;COUNT=4",
"2025-01-01",
"2025-12-31",
false,
false,
);
assert!(
result.len() <= 4,
"COUNT=4 should limit to at most 4 occurrences"
);
assert!(!result.is_empty(), "Should find some occurrences");
}
#[test]
fn date_until_limits_occurrences() {
let result = between_date_i64(
"2025-01-01",
"RRULE:FREQ=WEEKLY;UNTIL=20250201",
"2025-01-01",
"2025-12-31",
false,
false,
);
assert!(
!result.is_empty(),
"Should find some occurrences before UNTIL"
);
for date in &result {
assert!(
date.starts_with("2025-01"),
"All dates should be in January 2025"
);
}
}
#[test]
fn date_dtstart_in_future_returns_empty_for_past_range() {
let result = between_date_i64(
"2025-01-01",
"RRULE:FREQ=DAILY",
"2020-01-01",
"2020-12-31",
false,
false,
);
assert!(result.is_empty());
}
#[test]
fn date_f64_wrapper_works_correctly() {
let result = between_date_f64(
"2025-01-01",
"RRULE:FREQ=DAILY",
"2025-01-01",
"2025-01-10",
false,
false,
);
assert!(!result.is_empty(), "Should find daily occurrences");
for date in &result {
assert!(
date.starts_with("2025-01-"),
"Date should be in January 2025"
);
}
}
#[test]
fn datetime_f64_wrapper_works_correctly() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0) as f64;
let start = ts_ms(2025, 1, 1, 0, 0, 0) as f64;
let end = ts_ms(2025, 1, 5, 23, 59, 59) as f64;
let result = between_datetime_f64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
assert_eq!(result.len(), 5);
assert_eq!(result[0], ts_ms(2025, 1, 1, 10, 0, 0) as f64);
}
#[test]
fn extend_stops_at_max_range() {
let dt_start = ts_ms(2020, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=MONTHLY;INTERVAL=100",
start,
end,
true,
false,
);
assert!(result.len() <= MIN_OCCURRENCES + 1);
}
#[test]
fn extend_finds_exactly_min_occurrences_when_possible() {
let dt_start = ts_ms(2020, 6, 15, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 31, 23, 59, 59);
let result = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=YEARLY",
start,
end,
true,
false,
);
assert!(
result.len() >= MIN_OCCURRENCES,
"Expected at least {} occurrences, got {}",
MIN_OCCURRENCES,
result.len()
);
}
#[test]
fn no_extension_when_enough_in_range() {
let dt_start = ts_ms(2025, 1, 1, 10, 0, 0);
let start = ts_ms(2025, 1, 1, 0, 0, 0);
let end = ts_ms(2025, 1, 10, 23, 59, 59);
let without_extend = between_datetime_i64(
dt_start,
"UTC",
"RRULE:FREQ=DAILY",
start,
end,
false,
false,
);
let with_extend =
between_datetime_i64(dt_start, "UTC", "RRULE:FREQ=DAILY", start, end, true, false);
assert_eq!(without_extend, with_extend);
assert_eq!(without_extend.len(), 10);
}
}
#[cfg(test)]
mod tests_include_dtstart {
use super::*;
use chrono::{TimeZone, Utc};
use chrono_tz::Tz;
fn ts_ms_utc(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> i64 {
Utc.with_ymd_and_hms(year, month, day, hour, min, sec)
.single()
.unwrap()
.timestamp_millis()
}
#[test]
fn datetime_include_dtstart_matches_google_for_mismatched_byday_count() {
let tz: Tz = "Europe/Berlin".parse().unwrap();
let dtstart_local = tz.with_ymd_and_hms(2024, 5, 30, 20, 0, 0).single().unwrap();
let dt_start_utc_ms = dtstart_local.with_timezone(&Utc).timestamp_millis();
let start = ts_ms_utc(2024, 5, 1, 0, 0, 0);
let end = ts_ms_utc(2024, 6, 30, 23, 59, 59);
let rrule = "RRULE:FREQ=WEEKLY;COUNT=3;INTERVAL=1;BYDAY=WE";
let without = between_datetime_i64(
dt_start_utc_ms,
"Europe/Berlin",
rrule,
start,
end,
false, false, );
let with = between_datetime_i64(
dt_start_utc_ms,
"Europe/Berlin",
rrule,
start,
end,
false, true, );
let exp_dtstart = ts_ms_utc(2024, 5, 30, 18, 0, 0);
let exp_we1 = ts_ms_utc(2024, 6, 5, 18, 0, 0);
let exp_we2 = ts_ms_utc(2024, 6, 12, 18, 0, 0);
let exp_we3 = ts_ms_utc(2024, 6, 19, 18, 0, 0);
assert_eq!(with, vec![exp_dtstart, exp_we1, exp_we2, exp_we3]);
assert!(!without.contains(&exp_dtstart));
assert_eq!(without, vec![exp_we1, exp_we2, exp_we3]);
}
#[test]
fn date_include_dtstart_matches_google_for_mismatched_byday_count() {
let rrule = "RRULE:FREQ=WEEKLY;COUNT=3;INTERVAL=1;BYDAY=WE";
let without = between_date_i64(
"2024-05-30",
rrule,
"2024-05-01",
"2024-06-30",
false, false, );
let with = between_date_i64(
"2024-05-30",
rrule,
"2024-05-01",
"2024-06-30",
false, true, );
assert_eq!(
with,
vec![
"2024-05-30".to_string(),
"2024-06-05".to_string(),
"2024-06-12".to_string(),
"2024-06-19".to_string(),
]
);
assert_eq!(
without,
vec![
"2024-06-05".to_string(),
"2024-06-12".to_string(),
"2024-06-19".to_string(),
]
);
}
#[test]
fn test_is_datetime_excluded() {
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE:20231212T120000Z";
let ts = Utc
.with_ymd_and_hms(2023, 12, 12, 12, 0, 0)
.single()
.unwrap()
.timestamp_millis();
assert!(is_datetime_excluded(recurrence, ts, "UTC"));
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE:20231213T120000Z";
assert!(!is_datetime_excluded(recurrence, ts, "UTC"));
let recurrence =
"RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE:20231211T120000Z,20231212T120000Z,20231213T120000Z";
assert!(is_datetime_excluded(recurrence, ts, "UTC"));
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO";
assert!(!is_datetime_excluded(recurrence, ts, "UTC"));
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE;VALUE=DATE:20231212";
assert!(!is_datetime_excluded(recurrence, ts, "UTC"));
}
#[test]
fn test_is_date_excluded() {
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE;VALUE=DATE:20231212";
assert!(is_date_excluded(recurrence, "2023-12-12"));
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE;VALUE=DATE:20231213";
assert!(!is_date_excluded(recurrence, "2023-12-12"));
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE;VALUE=DATE:20231211,20231212,20231213";
assert!(is_date_excluded(recurrence, "2023-12-12"));
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO";
assert!(!is_date_excluded(recurrence, "2023-12-12"));
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO\nEXDATE:20231212T120000Z";
assert!(!is_date_excluded(recurrence, "2023-12-12"));
}
#[test]
fn test_is_datetime_excluded_tzid_without_seconds() {
let tz: chrono_tz::Tz = "Europe/Bucharest".parse().unwrap();
let dt_local = tz
.with_ymd_and_hms(2025, 12, 12, 8, 30, 0)
.single()
.unwrap();
let ts = dt_local.with_timezone(&Utc).timestamp_millis();
let recurrence = "RRULE:FREQ=WEEKLY;BYDAY=SA\nEXDATE;TZID=Europe/Bucharest:20251212T083000";
assert!(is_datetime_excluded(recurrence, ts, "Europe/Bucharest"));
}
}