feat(cal): add RecurrenceEngine for RFC 5545 RRULE expansion (GH-17) #46

Merged
KofTwentyTwo merged 1 commits from feature/GH-17-recurrence-expansion-engine into main 2026-07-23 00:49:33 +00:00
4 changed files with 126 additions and 0 deletions

No files matched your search

+3
View File
@@ -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"
+3
View File
@@ -15,3 +15,6 @@ thiserror.workspace = true
tracing.workspace = true
icalendar.workspace = true
chrono-tz.workspace = true
rrule.workspace = true
chrono.workspace = true
+2
View File
@@ -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;
+118
View File
@@ -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<Vec<CalendarEvent>, 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(_)));
}
}