Skip to main content

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}