1use crate::event::EventTime;
2use icalendar::{Component, DatePerhapsTime, Property};
3
4#[derive(Debug, Clone, Eq)]
5pub struct Recurrence {
6 pub rrule: String,
19 pub exdates: Vec<EventTime>,
20 pub rdates: Vec<EventTime>,
27}
28
29impl 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 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
112fn 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
143fn 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
172fn 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 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}