truth_engine/expander.rs
1//! RRULE expansion -- converts recurrence rule strings into concrete datetime instances.
2//!
3//! Wraps the `rrule` crate (v0.14) and `chrono-tz` to provide deterministic expansion
4//! of RFC 5545 recurrence rules with correct DST handling.
5
6use crate::error::{Result, TruthError};
7use chrono::{DateTime, Duration, Utc};
8use rrule::RRuleSet;
9
10/// A single expanded event instance with start and end times.
11#[derive(Debug, Clone, PartialEq)]
12pub struct ExpandedEvent {
13 pub start: DateTime<Utc>,
14 pub end: DateTime<Utc>,
15}
16
17/// Expand an RRULE string into concrete datetime instances.
18///
19/// # Arguments
20/// - `rrule` -- RFC 5545 RRULE string (e.g., "FREQ=WEEKLY;BYDAY=TU,TH")
21/// - `dtstart` -- Local datetime string (e.g., "2026-02-17T14:00:00")
22/// - `duration_minutes` -- Duration of each instance in minutes
23/// - `timezone` -- IANA timezone (e.g., "America/Los_Angeles")
24/// - `until` -- Optional end boundary for expansion (local datetime string)
25/// - `count` -- Optional maximum number of instances (overrides COUNT in rrule)
26///
27/// # Errors
28/// Returns `TruthError::InvalidRule` if the RRULE string is empty or unparseable.
29/// Returns `TruthError::InvalidTimezone` if the timezone is not a valid IANA identifier.
30pub fn expand_rrule(
31 rrule: &str,
32 dtstart: &str,
33 duration_minutes: u32,
34 timezone: &str,
35 until: Option<&str>,
36 count: Option<u32>,
37) -> Result<Vec<ExpandedEvent>> {
38 expand_rrule_with_exdates(
39 rrule,
40 dtstart,
41 duration_minutes,
42 timezone,
43 until,
44 count,
45 &[],
46 )
47}
48
49/// Expand an RRULE string into concrete datetime instances, with EXDATE exclusions.
50///
51/// Identical to [`expand_rrule`] but accepts a list of exception dates that will be
52/// excluded from the recurrence set (RFC 5545 Section 3.8.5.1).
53///
54/// # Arguments
55/// - `rrule` -- RFC 5545 RRULE string (e.g., "FREQ=WEEKLY;BYDAY=TU,TH")
56/// - `dtstart` -- Local datetime string (e.g., "2026-02-17T14:00:00")
57/// - `duration_minutes` -- Duration of each instance in minutes
58/// - `timezone` -- IANA timezone (e.g., "America/Los_Angeles")
59/// - `until` -- Optional end boundary for expansion (local datetime string)
60/// - `count` -- Optional maximum number of instances (overrides COUNT in rrule)
61/// - `exdates` -- Slice of local datetime strings to exclude (same format as `dtstart`)
62///
63/// # Errors
64/// Returns `TruthError::InvalidRule` if the RRULE string is empty or unparseable.
65/// Returns `TruthError::InvalidTimezone` if the timezone is not a valid IANA identifier.
66pub fn expand_rrule_with_exdates(
67 rrule: &str,
68 dtstart: &str,
69 duration_minutes: u32,
70 timezone: &str,
71 until: Option<&str>,
72 count: Option<u32>,
73 exdates: &[&str],
74) -> Result<Vec<ExpandedEvent>> {
75 // Validate inputs.
76 if rrule.is_empty() {
77 return Err(TruthError::InvalidRule("empty RRULE string".to_string()));
78 }
79
80 // Short-circuit: caller explicitly wants zero instances.
81 if count == Some(0) {
82 return Ok(Vec::new());
83 }
84
85 // Validate timezone by parsing it as a chrono-tz Tz.
86 let _tz: chrono_tz::Tz = timezone
87 .parse()
88 .map_err(|_| TruthError::InvalidTimezone(timezone.to_string()))?;
89
90 // Convert the dtstart from "2026-02-17T14:00:00" to iCalendar format "20260217T140000".
91 let dtstart_ical = dtstart.replace(['-', ':'], "");
92
93 // Build the RRULE text block. We may need to inject COUNT or UNTIL.
94 let mut rrule_str = rrule.to_string();
95
96 // If the caller provides an external `count`, inject it into the RRULE
97 // (unless the RRULE already has a COUNT).
98 if let Some(c) = count {
99 if !rrule_str.to_uppercase().contains("COUNT=") {
100 rrule_str = format!("{};COUNT={}", rrule_str, c);
101 }
102 }
103
104 // If the caller provides an `until`, inject it into the RRULE.
105 // The rrule crate requires UNTIL and DTSTART to share the same timezone.
106 // For UTC, UNTIL must end with "Z"; for other timezones, use bare local time.
107 if let Some(until_str) = until {
108 if !rrule_str.to_uppercase().contains("UNTIL=") {
109 let mut until_ical = until_str.replace(['-', ':'], "");
110 if timezone == "UTC" {
111 until_ical.push('Z');
112 }
113 rrule_str = format!("{};UNTIL={}", rrule_str, until_ical);
114 }
115 }
116
117 // Build the full iCalendar RRULE text with DTSTART and optional EXDATE lines.
118 let mut rrule_text = format!(
119 "DTSTART;TZID={}:{}\nRRULE:{}",
120 timezone, dtstart_ical, rrule_str
121 );
122
123 // Append EXDATE lines if any exclusion dates were provided.
124 if !exdates.is_empty() {
125 let exdate_icals: Vec<String> = exdates.iter().map(|d| d.replace(['-', ':'], "")).collect();
126 rrule_text.push_str(&format!(
127 "\nEXDATE;TZID={}:{}",
128 timezone,
129 exdate_icals.join(",")
130 ));
131 }
132
133 // Parse and expand.
134 let rrule_set: RRuleSet = rrule_text
135 .parse()
136 .map_err(|e| TruthError::InvalidRule(format!("{}", e)))?;
137
138 // Determine the max count for expansion to prevent unbounded expansion.
139 // When we have exdates, we need a higher limit because the rrule crate's
140 // `.all(limit)` counts BEFORE exdate filtering, so we may need more raw
141 // instances to get `count` results after exclusion. Add exdate count as buffer.
142 let exdate_buffer = exdates.len() as u16;
143 let max_count: u16 = count
144 .map(|c| (c as u16).saturating_add(exdate_buffer))
145 .unwrap_or(500);
146
147 let instances = rrule_set.all(max_count);
148 let duration = Duration::minutes(duration_minutes as i64);
149
150 let mut events: Vec<ExpandedEvent> = instances
151 .dates
152 .into_iter()
153 .map(|dt| {
154 let start_utc: DateTime<Utc> = dt.with_timezone(&Utc);
155 ExpandedEvent {
156 start: start_utc,
157 end: start_utc + duration,
158 }
159 })
160 .collect();
161
162 // If the caller specified an external count limit, truncate to that many results.
163 // (EXDATE filtering by the rrule crate may have already reduced the count, but
164 // the `.all()` limit is a pre-filter cap, not a post-filter cap.)
165 if let Some(c) = count {
166 events.truncate(c as usize);
167 }
168
169 Ok(events)
170}