Skip to main content

caldir_core/event/
recurrence.rs

1use crate::event::EventTime;
2use icalendar::{Component, DatePerhapsTime, Property};
3
4#[derive(Debug, Clone, Eq)]
5pub struct Recurrence {
6    /// RRULE value (after "RRULE:"), e.g. "FREQ=WEEKLY;BYDAY=MO".
7    ///
8    /// Stored as a string rather than a typed `rrule::RRule` for two reasons:
9    /// 1. Robustness — providers (especially CalDAV servers) emit RRULEs that
10    ///    the rrule crate can't always parse. Typing the field would turn every
11    ///    such case into a load error and break sync for that event.
12    /// 2. Round-tripping — `rrule::RRule`'s Display re-serializes from typed
13    ///    fields and drops redundant defaults (e.g. INTERVAL=1), causing
14    ///    spurious "modified" diffs on subsequent syncs.
15    ///
16    /// Parse with `rrule::RRule::from_str` at the call site when typed access
17    /// is needed (e.g. expanding occurrences).
18    pub rrule: String,
19    pub exdates: Vec<EventTime>,
20    /// RDATE values — explicit dates added to the recurrence set.
21    /// Sometimes emited by CalDAV servers like iCloud
22    ///
23    /// Note: RFC 5545 also allows PERIOD values (`DTSTART/DURATION` pairs)
24    /// in RDATE, but those are not modeled here — only date and date-time
25    /// values are parsed. PERIOD-valued RDATEs will be silently dropped.
26    pub rdates: Vec<EventTime>,
27}
28
29// EXDATE/RDATE are sets per RFC 5545, so equality must ignore order.
30// Otherwise a provider re-emitting the same dates in a different order
31// produces a spurious "modified" diff and a no-op push/pull.
32impl PartialEq for Recurrence {
33    fn eq(&self, other: &Self) -> bool {
34        self.rrule == other.rrule
35            && sorted_by_utc(&self.exdates) == sorted_by_utc(&other.exdates)
36            && sorted_by_utc(&self.rdates) == sorted_by_utc(&other.rdates)
37    }
38}
39
40fn sorted_by_utc(times: &[EventTime]) -> Vec<EventTime> {
41    let mut v = times.to_vec();
42    v.sort_by_key(|t| t.to_utc());
43    v
44}
45
46impl Recurrence {
47    pub fn new(rrule: impl Into<String>) -> Self {
48        Recurrence {
49            rrule: rrule.into(),
50            exdates: Vec::new(),
51            rdates: Vec::new(),
52        }
53    }
54
55    pub(crate) fn from_ical_event(event: &icalendar::Event) -> Option<Self> {
56        let rrule = event.property_value("RRULE")?;
57
58        let exdates = parse_event_time_list(event, "EXDATE");
59        let rdates = parse_event_time_list(event, "RDATE");
60
61        Some(Recurrence {
62            rrule: rrule.to_string(),
63            exdates,
64            rdates,
65        })
66    }
67
68    /// Return a copy truncated to end strictly before `before`.
69    ///
70    /// The returned recurrence:
71    /// - Has any existing `UNTIL=` and `COUNT=` fragments stripped from its RRULE
72    ///   and replaced with a fresh `UNTIL=<just before `before`>`.
73    /// - Has any EXDATEs at or after `before` removed (they are no longer
74    ///   meaningful once the series ends earlier).
75    ///
76    /// `dtstart` is the master event's start time; it determines the format of
77    /// the emitted UNTIL value (per RFC 5545 / the rrule crate's matching rule).
78    ///
79    /// Used to implement "split a recurring series at this instance" — a primitive
80    /// that any caldir client can use to translate a "this and future" edit into
81    /// two separate events on disk.
82    pub fn truncate_before(&self, dtstart: &EventTime, before: &EventTime) -> Recurrence {
83        let until_value = format_until_value(dtstart, before);
84        let truncated_rrule = with_until(&self.rrule, &until_value);
85
86        let before_utc = before.to_utc();
87        let kept_exdates: Vec<EventTime> = self
88            .exdates
89            .iter()
90            .filter(|ex| ex.to_utc() < before_utc)
91            .cloned()
92            .collect();
93
94        Recurrence {
95            rrule: truncated_rrule,
96            exdates: kept_exdates,
97            rdates: self.rdates.clone(),
98        }
99    }
100
101    pub(crate) fn apply_to(&self, event: &mut icalendar::Event) {
102        event.append_property(Property::new("RRULE", &self.rrule));
103        for exdate in &self.exdates {
104            event.append_multi_property(DatePerhapsTime::from(exdate).to_property("EXDATE"));
105        }
106        for rdate in &self.rdates {
107            event.append_multi_property(DatePerhapsTime::from(rdate).to_property("RDATE"));
108        }
109    }
110}
111
112/// Format an RRULE UNTIL value for a series with `dtstart`, set "just before"
113/// `before`. Format matches the rrule crate's expectations for the dtstart
114/// variant (see RFC 5545).
115fn format_until_value(dtstart: &EventTime, before: &EventTime) -> String {
116    use chrono::Duration;
117
118    match dtstart {
119        EventTime::Date(_) => {
120            let before_date = match before {
121                EventTime::Date(d) => *d,
122                _ => before.to_utc().date_naive(),
123            };
124            (before_date - Duration::days(1))
125                .format("%Y%m%d")
126                .to_string()
127        }
128        EventTime::DateTimeUtc(_) | EventTime::DateTimeZoned { .. } => {
129            let before_utc = before.to_utc();
130            (before_utc - Duration::seconds(1))
131                .format("%Y%m%dT%H%M%SZ")
132                .to_string()
133        }
134        EventTime::DateTimeFloating(_) => {
135            let before_naive = before.to_utc().naive_utc();
136            (before_naive - Duration::seconds(1))
137                .format("%Y%m%dT%H%M%S")
138                .to_string()
139        }
140    }
141}
142
143/// Return a copy of `rrule` with any existing `UNTIL=` and `COUNT=` fragments
144/// removed and a new `UNTIL=<until_value>` appended. Order of other fragments
145/// is preserved.
146fn with_until(rrule: &str, until_value: &str) -> String {
147    let mut parts: Vec<String> = rrule
148        .split(';')
149        .filter(|p| !p.is_empty())
150        .filter(|p| !p.starts_with("UNTIL=") && !p.starts_with("COUNT="))
151        .map(|p| p.to_string())
152        .collect();
153    parts.push(format!("UNTIL={}", until_value));
154    parts.join(";")
155}
156
157fn parse_event_time_list(event: &icalendar::Event, name: &str) -> Vec<EventTime> {
158    event
159        .multi_properties()
160        .get(name)
161        .map(|props| {
162            props
163                .iter()
164                .flat_map(split_property_values)
165                .filter_map(|p| DatePerhapsTime::from_property(&p))
166                .map(EventTime::from)
167                .collect()
168        })
169        .unwrap_or_default()
170}
171
172// RFC 5545 allows EXDATE/RDATE to carry multiple comma-separated values on a
173// single line. `DatePerhapsTime::from_property` parses the whole value as one
174// date(-time), so it returns None for comma-packed properties and every entry
175// is silently dropped. Split the value here and rebuild a property per entry,
176// preserving parameters (TZID, VALUE) so downstream parsing still sees them.
177fn split_property_values(prop: &Property) -> Vec<Property> {
178    let key = prop.key();
179    prop.value()
180        .split(',')
181        .map(str::trim)
182        .filter(|v| !v.is_empty())
183        .map(|v| {
184            let mut split = Property::new(key, v);
185            for (param_key, param) in prop.params() {
186                split.add_parameter(param_key, param.value());
187            }
188            split
189        })
190        .collect()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::test_utils::test_icalendar_event;
197    use chrono::{NaiveDate, TimeZone};
198    use pretty_assertions::assert_eq;
199
200    #[test]
201    fn from_ical_event_returns_none_when_no_rrule() {
202        let event = test_icalendar_event().done();
203
204        let recurrence = Recurrence::from_ical_event(&event);
205
206        assert_eq!(recurrence, None);
207    }
208
209    #[test]
210    fn from_ical_event_parses_rrule_value() {
211        let event = test_icalendar_event()
212            .append_property(Property::new("RRULE", "FREQ=WEEKLY;BYDAY=MO"))
213            .done();
214
215        let recurrence = Recurrence::from_ical_event(&event).unwrap();
216
217        assert_eq!(recurrence.rrule, "FREQ=WEEKLY;BYDAY=MO");
218        assert!(recurrence.exdates.is_empty());
219        assert!(recurrence.rdates.is_empty());
220    }
221
222    #[test]
223    fn from_ical_event_parses_multiple_exdates() {
224        let event = test_icalendar_event()
225            .append_property(Property::new("RRULE", "FREQ=DAILY"))
226            .append_multi_property(Property::new("EXDATE", "20260105").done())
227            .append_multi_property(Property::new("EXDATE", "20260108").done())
228            .done();
229
230        let recurrence = Recurrence::from_ical_event(&event).unwrap();
231
232        assert_eq!(
233            recurrence.exdates,
234            vec![
235                EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()),
236                EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 8).unwrap()),
237            ]
238        );
239    }
240
241    #[test]
242    fn from_ical_event_parses_comma_separated_exdates() {
243        let event = test_icalendar_event()
244            .append_property(Property::new("RRULE", "FREQ=DAILY"))
245            .append_multi_property(Property::new("EXDATE", "20260105,20260108").done())
246            .done();
247
248        let recurrence = Recurrence::from_ical_event(&event).unwrap();
249
250        assert_eq!(
251            recurrence.exdates,
252            vec![
253                EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()),
254                EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 8).unwrap()),
255            ]
256        );
257    }
258
259    #[test]
260    fn from_ical_event_parses_zoned_exdate() {
261        let mut exdate = Property::new("EXDATE", "20260105T100000");
262        exdate.add_parameter("TZID", "Europe/Stockholm");
263
264        let event = test_icalendar_event()
265            .append_property(Property::new("RRULE", "FREQ=WEEKLY"))
266            .append_multi_property(exdate.done())
267            .done();
268
269        let recurrence = Recurrence::from_ical_event(&event).unwrap();
270
271        assert_eq!(
272            recurrence.exdates,
273            vec![EventTime::DateTimeZoned {
274                datetime: NaiveDate::from_ymd_opt(2026, 1, 5)
275                    .unwrap()
276                    .and_hms_opt(10, 0, 0)
277                    .unwrap(),
278                tzid: "Europe/Stockholm".to_string(),
279            }]
280        );
281    }
282
283    #[test]
284    fn from_ical_event_normalizes_windows_exdate_timezone_to_iana() {
285        let mut exdate = Property::new("EXDATE", "20260105T100000");
286        exdate.add_parameter("TZID", "Pacific Standard Time");
287
288        let event = test_icalendar_event()
289            .append_property(Property::new("RRULE", "FREQ=WEEKLY"))
290            .append_multi_property(exdate.done())
291            .done();
292
293        let recurrence = Recurrence::from_ical_event(&event).unwrap();
294
295        assert_eq!(
296            recurrence.exdates,
297            vec![EventTime::DateTimeZoned {
298                datetime: NaiveDate::from_ymd_opt(2026, 1, 5)
299                    .unwrap()
300                    .and_hms_opt(10, 0, 0)
301                    .unwrap(),
302                tzid: "America/Los_Angeles".to_string(),
303            }]
304        );
305    }
306
307    #[test]
308    fn from_ical_event_parses_multiple_rdates() {
309        let event = test_icalendar_event()
310            .append_property(Property::new("RRULE", "FREQ=DAILY"))
311            .append_multi_property(Property::new("RDATE", "20260201").done())
312            .append_multi_property(Property::new("RDATE", "20260215").done())
313            .done();
314
315        let recurrence = Recurrence::from_ical_event(&event).unwrap();
316
317        assert_eq!(
318            recurrence.rdates,
319            vec![
320                EventTime::Date(NaiveDate::from_ymd_opt(2026, 2, 1).unwrap()),
321                EventTime::Date(NaiveDate::from_ymd_opt(2026, 2, 15).unwrap()),
322            ]
323        );
324    }
325
326    #[test]
327    fn from_ical_event_parses_zoned_rdate() {
328        let mut rdate = Property::new("RDATE", "20260201T100000");
329        rdate.add_parameter("TZID", "Europe/Stockholm");
330
331        let event = test_icalendar_event()
332            .append_property(Property::new("RRULE", "FREQ=WEEKLY"))
333            .append_multi_property(rdate.done())
334            .done();
335
336        let recurrence = Recurrence::from_ical_event(&event).unwrap();
337
338        assert_eq!(
339            recurrence.rdates,
340            vec![EventTime::DateTimeZoned {
341                datetime: NaiveDate::from_ymd_opt(2026, 2, 1)
342                    .unwrap()
343                    .and_hms_opt(10, 0, 0)
344                    .unwrap(),
345                tzid: "Europe/Stockholm".to_string(),
346            }]
347        );
348    }
349
350    #[test]
351    fn from_ical_event_normalizes_windows_rdate_timezone_to_iana() {
352        let mut rdate = Property::new("RDATE", "20260201T100000");
353        rdate.add_parameter("TZID", "Pacific Standard Time");
354
355        let event = test_icalendar_event()
356            .append_property(Property::new("RRULE", "FREQ=WEEKLY"))
357            .append_multi_property(rdate.done())
358            .done();
359
360        let recurrence = Recurrence::from_ical_event(&event).unwrap();
361
362        assert_eq!(
363            recurrence.rdates,
364            vec![EventTime::DateTimeZoned {
365                datetime: NaiveDate::from_ymd_opt(2026, 2, 1)
366                    .unwrap()
367                    .and_hms_opt(10, 0, 0)
368                    .unwrap(),
369                tzid: "America/Los_Angeles".to_string(),
370            }]
371        );
372    }
373
374    #[test]
375    fn from_ical_event_parses_exdates_and_rdates_together() {
376        let event = test_icalendar_event()
377            .append_property(Property::new("RRULE", "FREQ=WEEKLY"))
378            .append_multi_property(Property::new("EXDATE", "20260105").done())
379            .append_multi_property(Property::new("RDATE", "20260201").done())
380            .done();
381
382        let recurrence = Recurrence::from_ical_event(&event).unwrap();
383
384        assert_eq!(
385            recurrence.exdates,
386            vec![EventTime::Date(
387                NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()
388            )]
389        );
390        assert_eq!(
391            recurrence.rdates,
392            vec![EventTime::Date(
393                NaiveDate::from_ymd_opt(2026, 2, 1).unwrap()
394            )]
395        );
396    }
397
398    #[test]
399    fn apply_to_writes_rrule_exdates_and_rdates() {
400        let recurrence = Recurrence {
401            rrule: "FREQ=WEEKLY;BYDAY=MO".to_string(),
402            exdates: vec![
403                EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()),
404                EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 12).unwrap()),
405            ],
406            rdates: vec![
407                EventTime::Date(NaiveDate::from_ymd_opt(2026, 2, 1).unwrap()),
408                EventTime::Date(NaiveDate::from_ymd_opt(2026, 2, 15).unwrap()),
409            ],
410        };
411
412        let mut event = icalendar::Event::new();
413        recurrence.apply_to(&mut event);
414
415        assert_eq!(event.property_value("RRULE"), Some("FREQ=WEEKLY;BYDAY=MO"));
416        let exdates = event.multi_properties().get("EXDATE").unwrap();
417        assert_eq!(exdates.len(), 2);
418        assert_eq!(exdates[0].value(), "20260105");
419        assert_eq!(exdates[1].value(), "20260112");
420        let rdates = event.multi_properties().get("RDATE").unwrap();
421        assert_eq!(rdates.len(), 2);
422        assert_eq!(rdates[0].value(), "20260201");
423        assert_eq!(rdates[1].value(), "20260215");
424    }
425
426    #[test]
427    fn equality_ignores_exdate_and_rdate_order() {
428        let d1 = EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap());
429        let d2 = EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 12).unwrap());
430        let d3 = EventTime::Date(NaiveDate::from_ymd_opt(2026, 2, 1).unwrap());
431        let d4 = EventTime::Date(NaiveDate::from_ymd_opt(2026, 2, 15).unwrap());
432
433        let a = Recurrence {
434            rrule: "FREQ=WEEKLY".to_string(),
435            exdates: vec![d1.clone(), d2.clone()],
436            rdates: vec![d3.clone(), d4.clone()],
437        };
438        let b = Recurrence {
439            rrule: "FREQ=WEEKLY".to_string(),
440            exdates: vec![d2, d1],
441            rdates: vec![d4, d3],
442        };
443
444        assert_eq!(a, b);
445    }
446
447    #[test]
448    fn equality_detects_different_exdates() {
449        let a = Recurrence {
450            rrule: "FREQ=WEEKLY".to_string(),
451            exdates: vec![EventTime::Date(
452                NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
453            )],
454            rdates: vec![],
455        };
456        let b = Recurrence {
457            rrule: "FREQ=WEEKLY".to_string(),
458            exdates: vec![EventTime::Date(
459                NaiveDate::from_ymd_opt(2026, 1, 6).unwrap(),
460            )],
461            rdates: vec![],
462        };
463
464        assert_ne!(a, b);
465    }
466
467    #[test]
468    fn truncate_before_adds_until_for_utc_dtstart() {
469        let rec = Recurrence::new("FREQ=DAILY");
470        let dtstart =
471            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 1, 10, 0, 0).unwrap());
472        let before =
473            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 5, 10, 0, 0).unwrap());
474
475        let truncated = rec.truncate_before(&dtstart, &before);
476
477        assert_eq!(truncated.rrule, "FREQ=DAILY;UNTIL=20260405T095959Z");
478    }
479
480    #[test]
481    fn truncate_before_adds_until_for_date_dtstart() {
482        let rec = Recurrence::new("FREQ=DAILY");
483        let dtstart = EventTime::Date(NaiveDate::from_ymd_opt(2026, 4, 1).unwrap());
484        let before = EventTime::Date(NaiveDate::from_ymd_opt(2026, 4, 5).unwrap());
485
486        let truncated = rec.truncate_before(&dtstart, &before);
487
488        assert_eq!(truncated.rrule, "FREQ=DAILY;UNTIL=20260404");
489    }
490
491    #[test]
492    fn truncate_before_replaces_existing_until() {
493        let rec = Recurrence::new("FREQ=DAILY;UNTIL=20271231T235959Z");
494        let dtstart =
495            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 1, 10, 0, 0).unwrap());
496        let before =
497            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 5, 10, 0, 0).unwrap());
498
499        let truncated = rec.truncate_before(&dtstart, &before);
500
501        assert_eq!(truncated.rrule, "FREQ=DAILY;UNTIL=20260405T095959Z");
502    }
503
504    #[test]
505    fn truncate_before_replaces_existing_count() {
506        let rec = Recurrence::new("FREQ=DAILY;COUNT=100");
507        let dtstart =
508            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 1, 10, 0, 0).unwrap());
509        let before =
510            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 5, 10, 0, 0).unwrap());
511
512        let truncated = rec.truncate_before(&dtstart, &before);
513
514        assert_eq!(truncated.rrule, "FREQ=DAILY;UNTIL=20260405T095959Z");
515        assert!(!truncated.rrule.contains("COUNT="));
516    }
517
518    #[test]
519    fn truncate_before_drops_exdates_at_or_after_before() {
520        let kept =
521            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 3, 10, 0, 0).unwrap());
522        let dropped_at =
523            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 5, 10, 0, 0).unwrap());
524        let dropped_after =
525            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 6, 10, 0, 0).unwrap());
526
527        let rec = Recurrence {
528            rrule: "FREQ=DAILY".to_string(),
529            exdates: vec![kept.clone(), dropped_at, dropped_after],
530            rdates: vec![],
531        };
532
533        let truncated = rec.truncate_before(
534            &EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 1, 10, 0, 0).unwrap()),
535            &EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 5, 10, 0, 0).unwrap()),
536        );
537
538        assert_eq!(truncated.exdates, vec![kept]);
539    }
540
541    #[test]
542    fn truncate_before_keeps_rdates() {
543        // RDATEs aren't pruned by truncation — they're explicit additions.
544        let rdate =
545            EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 10, 10, 0, 0).unwrap());
546        let rec = Recurrence {
547            rrule: "FREQ=DAILY".to_string(),
548            exdates: vec![],
549            rdates: vec![rdate.clone()],
550        };
551
552        let truncated = rec.truncate_before(
553            &EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 1, 10, 0, 0).unwrap()),
554            &EventTime::DateTimeUtc(chrono::Utc.with_ymd_and_hms(2026, 4, 5, 10, 0, 0).unwrap()),
555        );
556
557        assert_eq!(truncated.rdates, vec![rdate]);
558    }
559
560    #[test]
561    fn apply_to_omits_exdate_and_rdate_when_empty() {
562        let recurrence = Recurrence::new("FREQ=DAILY");
563
564        let mut event = icalendar::Event::new();
565        recurrence.apply_to(&mut event);
566
567        assert!(event.multi_properties().get("EXDATE").is_none());
568        assert!(event.multi_properties().get("RDATE").is_none());
569    }
570}