use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use rrule::RRuleSet;
use crate::error::{CalDirError, CalDirResult};
use crate::event::{Event, EventStatus, Recurrence};
use crate::event_time::EventTime;
fn normalize_rrule_until(rrule: &str, start: &EventTime) -> String {
rrule
.split(';')
.map(|part| {
if !part.starts_with("UNTIL=") {
return part.to_string();
}
let value = &part[6..];
match start {
EventTime::DateTimeUtc(_) => {
if !value.ends_with('Z') {
if value.contains('T') {
format!("UNTIL={}Z", value)
} else {
format!("UNTIL={}T235959Z", value)
}
} else {
part.to_string()
}
}
EventTime::DateTimeZoned { .. } => {
if !value.ends_with('Z') {
if value.contains('T') {
format!("UNTIL={}Z", value)
} else {
format!("UNTIL={}T235959Z", value)
}
} else {
part.to_string()
}
}
_ => {
if value.ends_with('Z') {
format!("UNTIL={}", value.trim_end_matches('Z'))
} else {
part.to_string()
}
}
}
})
.collect::<Vec<_>>()
.join(";")
}
fn build_rrule_string(start: &EventTime, recurrence: &Recurrence, host_tz: rrule::Tz) -> String {
let mut lines = Vec::new();
let dtstart = match start {
EventTime::Date(d) if host_tz.is_local() => {
format!("DTSTART:{}T000000", d.format("%Y%m%d"))
}
EventTime::Date(d) => format!(
"DTSTART;TZID={}:{}T000000",
host_tz.name(),
d.format("%Y%m%d")
),
EventTime::DateTimeUtc(dt) => {
format!("DTSTART:{}", dt.format("%Y%m%dT%H%M%SZ"))
}
EventTime::DateTimeFloating(dt) if host_tz.is_local() => {
format!("DTSTART:{}", dt.format("%Y%m%dT%H%M%S"))
}
EventTime::DateTimeFloating(dt) => format!(
"DTSTART;TZID={}:{}",
host_tz.name(),
dt.format("%Y%m%dT%H%M%S")
),
EventTime::DateTimeZoned { datetime, tzid } => {
format!("DTSTART;TZID={}:{}", tzid, datetime.format("%Y%m%dT%H%M%S"))
}
};
lines.push(dtstart);
let rrule = normalize_rrule_until(&recurrence.rrule, start);
lines.push(format!("RRULE:{}", rrule));
for exdate in &recurrence.exdates {
let exdate_str = match exdate {
EventTime::Date(d) if host_tz.is_local() => {
format!("EXDATE:{}T000000", d.format("%Y%m%d"))
}
EventTime::Date(d) => format!(
"EXDATE;TZID={}:{}T000000",
host_tz.name(),
d.format("%Y%m%d")
),
EventTime::DateTimeUtc(dt) => {
format!("EXDATE:{}", dt.format("%Y%m%dT%H%M%SZ"))
}
EventTime::DateTimeFloating(dt) if host_tz.is_local() => {
format!("EXDATE:{}", dt.format("%Y%m%dT%H%M%S"))
}
EventTime::DateTimeFloating(dt) => format!(
"EXDATE;TZID={}:{}",
host_tz.name(),
dt.format("%Y%m%dT%H%M%S")
),
EventTime::DateTimeZoned { datetime, tzid } => {
format!("EXDATE;TZID={}:{}", tzid, datetime.format("%Y%m%dT%H%M%S"))
}
};
lines.push(exdate_str);
}
lines.join("\n")
}
fn occurrence_to_event_time(dt: &DateTime<rrule::Tz>, master_start: &EventTime) -> EventTime {
match master_start {
EventTime::Date(_) => EventTime::Date(dt.date_naive()),
EventTime::DateTimeUtc(_) => EventTime::DateTimeUtc(dt.with_timezone(&Utc)),
EventTime::DateTimeFloating(_) => EventTime::DateTimeFloating(dt.naive_local()),
EventTime::DateTimeZoned { tzid, .. } => EventTime::DateTimeZoned {
datetime: dt.naive_local(),
tzid: tzid.clone(),
},
}
}
pub fn expand_recurring_event(
master: &Event,
range_start: DateTime<Utc>,
range_end: DateTime<Utc>,
overrides: &HashMap<String, Event>,
host_tz: rrule::Tz,
) -> CalDirResult<Vec<Event>> {
let recurrence = match &master.recurrence {
Some(r) => r,
None => return Ok(Vec::new()),
};
let rrule_str = build_rrule_string(&master.start, recurrence, host_tz);
let rrule_set: RRuleSet = rrule_str.parse().map_err(|e| {
CalDirError::IcsParse(format!(
"Failed to parse RRULE for event '{}': {}",
master.uid, e
))
})?;
let tz: rrule::Tz = Utc.into();
let after = range_start.with_timezone(&tz);
let before = range_end.with_timezone(&tz);
let result = rrule_set.after(after).before(before).all(365);
let duration = match (master.start.to_utc(), master.end.to_utc()) {
(Some(s), Some(e)) => e - s,
_ => Duration::zero(),
};
let mut events = Vec::new();
for occ_dt in &result.dates {
let occ_event_time = occurrence_to_event_time(occ_dt, &master.start);
let ics_key = occ_event_time.to_ics_string();
if let Some(override_event) = overrides.get(&ics_key) {
if override_event.status != EventStatus::Cancelled {
events.push(override_event.clone());
}
} else {
let instance_end = match (&master.start, &master.end) {
(EventTime::Date(d_start), EventTime::Date(d_end)) => {
let day_diff = (*d_end - *d_start).num_days();
EventTime::Date(occ_dt.date_naive() + Duration::days(day_diff))
}
(EventTime::DateTimeUtc(_), _) => {
EventTime::DateTimeUtc(occ_dt.with_timezone(&Utc) + duration)
}
(EventTime::DateTimeFloating(_), _) => {
EventTime::DateTimeFloating(occ_dt.naive_local() + duration)
}
(EventTime::DateTimeZoned { tzid, .. }, _) => EventTime::DateTimeZoned {
datetime: occ_dt.naive_local() + duration,
tzid: tzid.clone(),
},
_ => EventTime::DateTimeUtc(occ_dt.with_timezone(&Utc) + duration),
};
events.push(Event {
uid: master.uid.clone(),
summary: master.summary.clone(),
description: master.description.clone(),
location: master.location.clone(),
start: occ_event_time.clone(),
end: instance_end,
status: master.status.clone(),
recurrence: None,
recurrence_id: Some(occ_event_time),
reminders: master.reminders.clone(),
transparency: master.transparency.clone(),
organizer: master.organizer.clone(),
attendees: master.attendees.clone(),
conference_url: master.conference_url.clone(),
updated: master.updated,
sequence: master.sequence,
custom_properties: master.custom_properties.clone(),
});
}
}
Ok(events)
}
pub fn truncate_recurrence_before(
recurrence: &Recurrence,
dtstart: &EventTime,
before: &EventTime,
) -> Recurrence {
let until_value = format_until_value(dtstart, before);
let truncated_rrule = with_until(&recurrence.rrule, &until_value);
let before_utc = before.to_utc();
let kept_exdates: Vec<EventTime> = recurrence
.exdates
.iter()
.filter(|ex| match (ex.to_utc(), before_utc) {
(Some(ex_utc), Some(b_utc)) => ex_utc < b_utc,
_ => true,
})
.cloned()
.collect();
Recurrence {
rrule: truncated_rrule,
exdates: kept_exdates,
}
}
fn format_until_value(dtstart: &EventTime, before: &EventTime) -> String {
use chrono::Duration;
match dtstart {
EventTime::Date(_) => {
let before_date = match before {
EventTime::Date(d) => *d,
_ => before
.to_utc()
.map(|dt| dt.date_naive())
.unwrap_or_default(),
};
(before_date - Duration::days(1))
.format("%Y%m%d")
.to_string()
}
EventTime::DateTimeUtc(_) | EventTime::DateTimeZoned { .. } => {
let before_utc = before.to_utc().unwrap_or_default();
(before_utc - Duration::seconds(1))
.format("%Y%m%dT%H%M%SZ")
.to_string()
}
EventTime::DateTimeFloating(_) => {
let before_naive = before.to_utc().unwrap_or_default().naive_utc();
(before_naive - Duration::seconds(1))
.format("%Y%m%dT%H%M%S")
.to_string()
}
}
}
fn with_until(rrule: &str, until_value: &str) -> String {
let mut parts: Vec<String> = rrule
.split(';')
.filter(|p| !p.is_empty())
.filter(|p| !p.starts_with("UNTIL=") && !p.starts_with("COUNT="))
.map(|p| p.to_string())
.collect();
parts.push(format!("UNTIL={}", until_value));
parts.join(";")
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
#[test]
fn test_zoned_dtstart_with_utc_until() {
let start = EventTime::DateTimeZoned {
datetime: NaiveDateTime::new(
NaiveDate::from_ymd_opt(1968, 5, 8).unwrap(),
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
),
tzid: "Europe/Stockholm".to_string(),
};
let recurrence = Recurrence {
rrule: "FREQ=YEARLY;UNTIL=20080507T120000Z".to_string(),
exdates: vec![],
};
let rrule_str = build_rrule_string(&start, &recurrence, rrule::Tz::LOCAL);
eprintln!("Generated rrule string:\n{}", rrule_str);
let result: Result<RRuleSet, _> = rrule_str.parse();
assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
}
#[test]
fn test_parsed_outlook_windows_tzid_expands_correctly() {
use crate::ics::parse_event;
let ics = r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:TEST
BEGIN:VEVENT
UID:outlook-event
SUMMARY:Outlook Recurring Event
DTSTART;TZID=E. South America Standard Time:20250320T090000
DTEND;TZID=E. South America Standard Time:20250320T100000
RRULE:FREQ=WEEKLY;BYDAY=MO
END:VEVENT
END:VCALENDAR"#;
let event = parse_event(ics).expect("Should parse");
let recurrence = event.recurrence.as_ref().expect("Should have recurrence");
let rrule_str = build_rrule_string(&event.start, recurrence, rrule::Tz::LOCAL);
let result: Result<RRuleSet, _> = rrule_str.parse();
assert!(
result.is_ok(),
"Expected RRULE to parse after Windows->IANA normalization, got: {:?}",
result.err()
);
}
#[test]
fn test_allday_dtstart_with_date_until() {
let start = EventTime::Date(NaiveDate::from_ymd_opt(2008, 5, 8).unwrap());
let recurrence = Recurrence {
rrule: "FREQ=YEARLY;UNTIL=20220507".to_string(),
exdates: vec![],
};
let rrule_str = build_rrule_string(&start, &recurrence, rrule::Tz::LOCAL);
eprintln!("Generated rrule string:\n{}", rrule_str);
let result: Result<RRuleSet, _> = rrule_str.parse();
assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
}
#[test]
fn truncate_utc_event() {
let dtstart = EventTime::DateTimeUtc("2026-04-01T10:00:00Z".parse().unwrap());
let recurrence = Recurrence {
rrule: "FREQ=DAILY".to_string(),
exdates: vec![],
};
let split = EventTime::DateTimeUtc("2026-04-03T10:00:00Z".parse().unwrap());
let truncated = truncate_recurrence_before(&recurrence, &dtstart, &split);
assert_eq!(truncated.rrule, "FREQ=DAILY;UNTIL=20260403T095959Z");
let rrule_str = build_rrule_string(&dtstart, &truncated, rrule::Tz::LOCAL);
let parsed: Result<RRuleSet, _> = rrule_str.parse();
assert!(parsed.is_ok(), "Failed to parse: {:?}", parsed.err());
}
#[test]
fn truncate_strips_existing_until_and_count() {
let dtstart = EventTime::DateTimeUtc("2026-04-01T10:00:00Z".parse().unwrap());
let recurrence = Recurrence {
rrule: "FREQ=DAILY;COUNT=10;BYDAY=MO".to_string(),
exdates: vec![],
};
let split = EventTime::DateTimeUtc("2026-04-03T10:00:00Z".parse().unwrap());
let truncated = truncate_recurrence_before(&recurrence, &dtstart, &split);
assert!(!truncated.rrule.contains("COUNT="));
assert_eq!(truncated.rrule.matches("UNTIL=").count(), 1);
assert!(truncated.rrule.contains("BYDAY=MO"));
assert!(truncated.rrule.ends_with("UNTIL=20260403T095959Z"));
}
#[test]
fn truncate_allday_event() {
let dtstart = EventTime::Date(NaiveDate::from_ymd_opt(2026, 4, 1).unwrap());
let recurrence = Recurrence {
rrule: "FREQ=DAILY".to_string(),
exdates: vec![],
};
let split = EventTime::Date(NaiveDate::from_ymd_opt(2026, 4, 3).unwrap());
let truncated = truncate_recurrence_before(&recurrence, &dtstart, &split);
assert_eq!(truncated.rrule, "FREQ=DAILY;UNTIL=20260402");
}
#[test]
fn truncate_drops_exdates_at_or_after_split() {
let dtstart = EventTime::DateTimeUtc("2026-04-01T10:00:00Z".parse().unwrap());
let kept = EventTime::DateTimeUtc("2026-04-02T10:00:00Z".parse().unwrap());
let dropped_at = EventTime::DateTimeUtc("2026-04-03T10:00:00Z".parse().unwrap());
let dropped_after = EventTime::DateTimeUtc("2026-04-05T10:00:00Z".parse().unwrap());
let recurrence = Recurrence {
rrule: "FREQ=DAILY".to_string(),
exdates: vec![kept.clone(), dropped_at, dropped_after],
};
let split = EventTime::DateTimeUtc("2026-04-03T10:00:00Z".parse().unwrap());
let truncated = truncate_recurrence_before(&recurrence, &dtstart, &split);
assert_eq!(truncated.exdates, vec![kept]);
}
#[test]
fn truncate_zoned_event_emits_utc_until() {
let dtstart = EventTime::DateTimeZoned {
datetime: NaiveDateTime::new(
NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(),
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
),
tzid: "Europe/Stockholm".to_string(),
};
let recurrence = Recurrence {
rrule: "FREQ=DAILY".to_string(),
exdates: vec![],
};
let split = EventTime::DateTimeZoned {
datetime: NaiveDateTime::new(
NaiveDate::from_ymd_opt(2026, 4, 3).unwrap(),
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
),
tzid: "Europe/Stockholm".to_string(),
};
let truncated = truncate_recurrence_before(&recurrence, &dtstart, &split);
assert!(
truncated.rrule.ends_with("Z"),
"zoned UNTIL must be in UTC form: {}",
truncated.rrule
);
let rrule_str = build_rrule_string(&dtstart, &truncated, rrule::Tz::LOCAL);
let parsed: Result<RRuleSet, _> = rrule_str.parse();
assert!(parsed.is_ok(), "Failed to parse: {:?}", parsed.err());
}
}