diff --git a/Cargo.toml b/Cargo.toml index 5a63bfc..02560df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ async-imap = { version = "0.9", default-features = false, features = ["runtime-t wiremock = "0.6" icalendar = "0.15" chrono-tz = "0.9" +rrule = "0.10" +chrono = "0.4" + diff --git a/crates/nuncio-cal/Cargo.toml b/crates/nuncio-cal/Cargo.toml index 48fbe1a..371dffa 100644 --- a/crates/nuncio-cal/Cargo.toml +++ b/crates/nuncio-cal/Cargo.toml @@ -15,3 +15,6 @@ thiserror.workspace = true tracing.workspace = true icalendar.workspace = true chrono-tz.workspace = true +rrule.workspace = true +chrono.workspace = true + diff --git a/crates/nuncio-cal/src/lib.rs b/crates/nuncio-cal/src/lib.rs index 76e1e81..2d21c6d 100644 --- a/crates/nuncio-cal/src/lib.rs +++ b/crates/nuncio-cal/src/lib.rs @@ -1,5 +1,7 @@ //! iCalendar (RFC 5545), CalDAV (RFC 4791), and recurrence engine for Nuncio. pub mod parser; +pub mod rrule; pub use parser::{CalendarError, IcalParserAdapter}; +pub use rrule::RecurrenceEngine; diff --git a/crates/nuncio-cal/src/rrule.rs b/crates/nuncio-cal/src/rrule.rs new file mode 100644 index 0000000..708b032 --- /dev/null +++ b/crates/nuncio-cal/src/rrule.rs @@ -0,0 +1,118 @@ +//! iCalendar recurrence expansion engine wrapping `rrule`. + +use chrono::{DateTime, Utc}; +use nuncio_core::model::CalendarEvent; +use rrule::RRuleSet; +use std::str::FromStr; + +use crate::parser::CalendarError; + +/// Recurrence engine managing RFC 5545 RRULE expansion within date windows. +pub struct RecurrenceEngine; + +impl RecurrenceEngine { + /// Maximum safety limit for expanded instances to prevent infinite loops. + pub const MAX_EXPANSION_COUNT: u16 = 500; + + /// Expand recurring [`CalendarEvent`] instances within `[start_window, end_window]` timestamp bounds. + pub fn expand_occurrences( + event: &CalendarEvent, + start_window: i64, + end_window: i64, + ) -> Result, CalendarError> { + let rrule_str = match &event.rrule { + Some(rule) if !rule.trim().is_empty() => rule, + _ => return Ok(vec![event.clone()]), + }; + + let start_dt = DateTime::from_timestamp(event.start_time, 0) + .ok_or_else(|| CalendarError::ParseFailed("invalid start timestamp".to_string()))?; + + let rrule_set_str = format!("DTSTART:{}\nRRULE:{}", start_dt.format("%Y%m%dT%H%M%SZ"), rrule_str); + + let rrule_set: RRuleSet = rrule_set_str + .parse() + .map_err(|e: rrule::RRuleError| CalendarError::ParseFailed(e.to_string()))?; + + let window_start_dt = DateTime::from_timestamp(start_window, 0) + .ok_or_else(|| CalendarError::ParseFailed("invalid window start timestamp".to_string()))?; + + let window_end_dt = DateTime::from_timestamp(end_window, 0) + .ok_or_else(|| CalendarError::ParseFailed("invalid window end timestamp".to_string()))?; + + let duration = event.end_time - event.start_time; + + let results = rrule_set + .into_iter() + .after(window_start_dt) + .before(window_end_dt) + .take(Self::MAX_EXPANSION_COUNT as usize); + + let mut occurrences = Vec::new(); + for (index, dt) in results.enumerate() { + let instance_start = dt.timestamp(); + let mut instance = event.clone(); + instance.id = format!("{}_occ_{}", event.id, index); + instance.start_time = instance_start; + instance.end_time = instance_start + duration; + occurrences.push(instance); + } + + if occurrences.is_empty() { + // Return original if no occurrences fall strictly inside window + Ok(vec![event.clone()]) + } else { + Ok(occurrences) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_recurring_event() -> CalendarEvent { + CalendarEvent { + id: "evt-weekly".to_string(), + account_id: "acct-1".to_string(), + calendar_id: "cal-1".to_string(), + summary: "Weekly Standup".to_string(), + start_time: 1704067200, // 2024-01-01T00:00:00Z + end_time: 1704070800, // +1 hr + rrule: Some("FREQ=WEEKLY;INTERVAL=1".to_string()), + location: Some("Zoom".to_string()), + } + } + + #[test] + fn non_recurring_event_returns_single_instance() { + let mut event = sample_recurring_event(); + event.rrule = None; + + let results = RecurrenceEngine::expand_occurrences(&event, 1704067200, 1709251200) + .expect("expansion succeeds"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "evt-weekly"); + } + + #[test] + fn weekly_recurring_event_expands_multiple_instances() { + let event = sample_recurring_event(); + // Window covers Jan 1 2024 to Feb 1 2024 (4 weeks) + let results = RecurrenceEngine::expand_occurrences(&event, 1704067200, 1706745600) + .expect("expansion succeeds"); + + assert!(results.len() >= 3); + assert!(results[0].id.contains("evt-weekly_occ_")); + } + + #[test] + fn invalid_rrule_returns_parse_error() { + let mut event = sample_recurring_event(); + event.rrule = Some("INVALID_RRULE".to_string()); + + let err = RecurrenceEngine::expand_occurrences(&event, 1704067200, 1706745600) + .expect_err("should fail with invalid rrule"); + assert!(matches!(err, CalendarError::ParseFailed(_))); + } +}