Skip to main content

caldir_core/diff/
calendar_diff.rs

1use std::collections::{HashMap, HashSet};
2
3use super::event_change::EventChange;
4use crate::calendar::SyncBases;
5use crate::event::{EventInstanceId, EventUid, Status};
6use crate::{CalendarEvent, DateRange, Event, RemoteEvent};
7
8pub struct CalendarDiff {
9    outgoing: Vec<EventChange>,
10    incoming: Vec<EventChange>,
11}
12
13impl CalendarDiff {
14    pub(crate) fn compute(
15        local_events: Vec<CalendarEvent>,
16        remote_events: Vec<RemoteEvent>,
17        sync_bases: &SyncBases,
18        range: &DateRange,
19    ) -> Self {
20        let (from, to) = range.query_bounds();
21        let local_event_ids: HashSet<_> = local_events
22            .iter()
23            .map(|e| e.event().event_instance_id())
24            .collect();
25
26        let remote_by_id: HashMap<_, &RemoteEvent> = remote_events
27            .iter()
28            .map(|e| (e.event().event_instance_id(), e))
29            .collect();
30
31        let remote_live_master_uids: HashSet<_> = remote_events
32            .iter()
33            .map(RemoteEvent::event)
34            .filter(|event| {
35                event.recurrence.is_some()
36                    && event.recurrence_id.is_none()
37                    && event.status != Status::Cancelled
38            })
39            .map(|event| event.uid.clone())
40            .collect();
41
42        let local_masters: HashMap<_, &Event> = local_events
43            .iter()
44            .map(CalendarEvent::event)
45            .filter(|event| event.recurrence.is_some() && event.recurrence_id.is_none())
46            .map(|event| (event.uid.clone(), event))
47            .collect();
48
49        let mut master_uids: HashSet<_> = local_events
50            .iter()
51            .filter(|e| e.event().recurrence.is_some() && e.event().recurrence_id.is_none())
52            .map(|e| e.event().uid.clone())
53            .collect();
54
55        master_uids.extend(
56            remote_events
57                .iter()
58                .map(RemoteEvent::event)
59                .filter(|event| {
60                    event.recurrence.is_some()
61                        && event.recurrence_id.is_none()
62                        && event.status != Status::Cancelled
63                        && !local_event_ids.contains(&event.event_instance_id())
64                        && sync_bases.get(&event.event_instance_id()).is_none()
65                })
66                .map(|event| event.uid.clone()),
67        );
68
69        let mut outgoing = Vec::new();
70        let mut incoming = Vec::new();
71
72        for local_event in &local_events {
73            let event = local_event.event();
74            let id = event.event_instance_id();
75
76            // In both local and remote: skip if equal, otherwise update
77            if let Some(remote_event) = remote_by_id.get(&id) {
78                if event == remote_event.event() {
79                    continue;
80                }
81
82                // Both cancelled: treat as in-sync regardless of field drift.
83                // Cancelled events are historical; we don't churn syncing them.
84                if event.status == Status::Cancelled
85                    && remote_event.event().status == Status::Cancelled
86                {
87                    continue;
88                }
89
90                // Never push an unspecified (None) visibility — inherit the remote's.
91                let mut to_push = event.clone();
92                if to_push.visibility.is_none() {
93                    to_push.visibility = remote_event.event().visibility;
94                }
95
96                let base = sync_bases.get(&id).and_then(Option::as_deref);
97                let direction = if &to_push == remote_event.event() {
98                    Some(UpdateDirection::Pull)
99                } else {
100                    update_direction(local_event, remote_event, base)
101                };
102
103                match direction {
104                    Some(UpdateDirection::Push) => outgoing.push(EventChange::Update {
105                        from: remote_event.event().clone(),
106                        to: to_push,
107                    }),
108                    Some(UpdateDirection::Pull) => incoming.push(EventChange::Update {
109                        from: event.clone(),
110                        to: remote_event.event().clone(),
111                    }),
112                    None => {}
113                }
114                continue;
115            }
116
117            // Out-of-window events aren't in the remote response, so we
118            // can't tell if they're deleted or just out of range. Skip.
119            if !event.has_occurrence_in_range(from, to) {
120                continue;
121            }
122
123            if sync_bases.get(&id).is_some()
124                || is_orphaned_override(
125                    event,
126                    &remote_live_master_uids,
127                    &local_masters,
128                    sync_bases,
129                    range,
130                )
131            {
132                incoming.push(EventChange::Delete(event.clone()));
133            } else {
134                outgoing.push(EventChange::Create(event.clone()));
135            }
136        }
137
138        for remote_event in &remote_events {
139            let id = remote_event.event().event_instance_id();
140
141            // Already in local and remote, skip
142            if local_event_ids.contains(&id) {
143                continue;
144            }
145
146            // Skip cancelled tombstones, but materialize recurring overrides
147            // while their master remains so its RRULE does not expand them.
148            if remote_event.event().status == Status::Cancelled {
149                let event = remote_event.event();
150
151                if event.recurrence_id.is_some() && master_uids.contains(&event.uid) {
152                    incoming.push(EventChange::Create(event.clone()));
153                }
154
155                continue;
156            }
157
158            if sync_bases.get(&id).is_some() {
159                // Remote event was in local, gone now. Delete remotely.
160                outgoing.push(EventChange::Delete(remote_event.event().clone()));
161            } else {
162                // Not in local, create it!
163                incoming.push(EventChange::Create(remote_event.event().clone()));
164            }
165        }
166
167        CalendarDiff { outgoing, incoming }
168    }
169
170    pub fn incoming(&self) -> &[EventChange] {
171        &self.incoming
172    }
173
174    pub fn outgoing(&self) -> &[EventChange] {
175        &self.outgoing
176    }
177
178    pub fn is_empty(&self) -> bool {
179        self.outgoing.is_empty() && self.incoming.is_empty()
180    }
181
182    /// Drop outgoing changes. Used for read-only calendars where outgoing
183    /// could never be applied — surfacing them as pending pushes is misleading.
184    pub fn discard_outgoing(&mut self) {
185        self.outgoing.clear();
186    }
187}
188
189fn is_orphaned_override(
190    event: &Event,
191    remote_live_master_uids: &HashSet<EventUid>,
192    local_masters: &HashMap<EventUid, &Event>,
193    sync_bases: &SyncBases,
194    range: &DateRange,
195) -> bool {
196    if event.recurrence_id.is_none() || remote_live_master_uids.contains(&event.uid) {
197        return false;
198    }
199
200    let master_id = EventInstanceId::new(event.uid.clone(), None);
201    if sync_bases.get(&master_id).is_none() {
202        return false;
203    }
204
205    let Some(master) = local_masters.get(&event.uid) else {
206        return true;
207    };
208
209    let (from, to) = range.query_bounds();
210    master.has_occurrence_in_range(from, to)
211}
212
213#[cfg(test)]
214impl CalendarDiff {
215    pub(crate) fn from_changes(outgoing: Vec<EventChange>, incoming: Vec<EventChange>) -> Self {
216        Self { outgoing, incoming }
217    }
218}
219
220enum UpdateDirection {
221    Push,
222    Pull,
223}
224
225fn update_direction(
226    local: &CalendarEvent,
227    remote: &RemoteEvent,
228    base: Option<&crate::Event>,
229) -> Option<UpdateDirection> {
230    // If base exists, use that for determining direction:
231    if let Some(base) = base {
232        let local_has_changes = local.event() != base;
233        let remote_has_changes = remote.event() != base;
234
235        match (local_has_changes, remote_has_changes) {
236            (false, false) => return None,
237            (true, false) => return Some(UpdateDirection::Push),
238            (false, true) => return Some(UpdateDirection::Pull),
239            (true, true) => {}
240        }
241    }
242
243    // Both sides changed, or no base exists for this legacy sync state (TODO: resolve conflicts?),
244    // use mtime / LAST-MODIFIED to determine direction. If both are equal, default to pull.
245    if local_is_newer(local, remote) {
246        Some(UpdateDirection::Push)
247    } else {
248        Some(UpdateDirection::Pull)
249    }
250}
251
252fn local_is_newer(local: &CalendarEvent, remote: &RemoteEvent) -> bool {
253    if remote.modified_at().is_none() && local.event().sequence != remote.event().sequence {
254        return local.event().sequence > remote.event().sequence;
255    }
256
257    match (local.modified_at(), remote.modified_at()) {
258        (Some(l), Some(r)) => l > r,
259        (Some(_), None) => true,
260        (None, Some(_)) => false,
261        (None, None) => true,
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::Event;
269    use crate::event::{EventTime, Recurrence, RecurrenceId};
270    use crate::test_utils::{test_calendar, test_calendar_event, test_event};
271    use chrono::{TimeZone, Utc};
272    use pretty_assertions::assert_eq;
273
274    fn recurring_series() -> (Event, Event) {
275        let mut master = test_event();
276        master.recurrence = Some(Recurrence::new("FREQ=DAILY"));
277
278        let mut override_event = master.occurrence_at(master.start.clone());
279        override_event.summary = Some("Overridden occurrence".to_string());
280
281        (master, override_event)
282    }
283
284    #[test]
285    fn new_local_event_becomes_outgoing_create() {
286        let (_tmp, calendar_event) = test_calendar_event();
287        let new_event = calendar_event.event().clone();
288
289        let local_events = vec![calendar_event];
290        let remote_events = vec![];
291        let synced_ids = SyncBases::new();
292
293        let diff = CalendarDiff::compute(
294            local_events,
295            remote_events,
296            &synced_ids,
297            &DateRange::default(),
298        );
299
300        assert_eq!(diff.outgoing, vec![EventChange::Create(new_event)]);
301        assert_eq!(diff.incoming, vec![]);
302    }
303
304    #[test]
305    fn new_remote_event_becomes_incoming_create() {
306        let new_event = test_event();
307
308        let local_events = vec![];
309        let remote_events = vec![RemoteEvent::new(new_event.clone())];
310        let synced_ids = SyncBases::new();
311
312        let diff = CalendarDiff::compute(
313            local_events,
314            remote_events,
315            &synced_ids,
316            &DateRange::default(),
317        );
318
319        assert_eq!(diff.outgoing, vec![]);
320        assert_eq!(diff.incoming, vec![EventChange::Create(new_event)]);
321    }
322
323    #[test]
324    fn orphaned_override_is_deleted_after_master_already_deleted() {
325        for full_base in [true, false] {
326            let (_tmp, calendar) = test_calendar();
327            let (master, override_event) = recurring_series();
328            let calendar_event = calendar.create_event(override_event.clone()).unwrap();
329
330            let mut sync_bases = SyncBases::new();
331            if full_base {
332                sync_bases.insert_event_base(master.event_instance_id(), master);
333            } else {
334                sync_bases.insert_known_event_id(master.event_instance_id());
335            }
336
337            let diff = CalendarDiff::compute(
338                vec![calendar_event],
339                vec![],
340                &sync_bases,
341                &DateRange::default(),
342            );
343
344            assert_eq!(diff.outgoing, vec![]);
345            assert_eq!(diff.incoming, vec![EventChange::Delete(override_event)]);
346        }
347    }
348
349    #[test]
350    fn cancelled_remote_master_is_not_considered_live() {
351        let (_tmp, calendar) = test_calendar();
352        let (master, override_event) = recurring_series();
353        let local_master = calendar.create_event(master.clone()).unwrap();
354        let local_override = calendar.create_event(override_event.clone()).unwrap();
355
356        let mut cancelled_master = master.clone();
357        cancelled_master.status = Status::Cancelled;
358
359        let mut sync_bases = SyncBases::new();
360        sync_bases.insert_event_base(master.event_instance_id(), master.clone());
361
362        let diff = CalendarDiff::compute(
363            vec![local_master, local_override],
364            vec![RemoteEvent::new(cancelled_master.clone())],
365            &sync_bases,
366            &DateRange::default(),
367        );
368
369        assert_eq!(diff.outgoing, vec![]);
370        assert_eq!(
371            diff.incoming,
372            vec![
373                EventChange::Update {
374                    from: master,
375                    to: cancelled_master,
376                },
377                EventChange::Delete(override_event),
378            ]
379        );
380    }
381
382    #[test]
383    fn new_local_series_still_pushes_master_and_override() {
384        let (_tmp, calendar) = test_calendar();
385        let (master, override_event) = recurring_series();
386        let local_master = calendar.create_event(master.clone()).unwrap();
387        let local_override = calendar.create_event(override_event.clone()).unwrap();
388
389        let diff = CalendarDiff::compute(
390            vec![local_master, local_override],
391            vec![],
392            &SyncBases::new(),
393            &DateRange::default(),
394        );
395
396        assert_eq!(
397            diff.outgoing,
398            vec![
399                EventChange::Create(master),
400                EventChange::Create(override_event),
401            ]
402        );
403        assert_eq!(diff.incoming, vec![]);
404    }
405
406    #[test]
407    fn fresh_override_of_live_remote_series_still_pushes() {
408        let (_tmp, calendar) = test_calendar();
409        let (master, override_event) = recurring_series();
410        let local_master = calendar.create_event(master.clone()).unwrap();
411        let local_override = calendar.create_event(override_event.clone()).unwrap();
412
413        let mut sync_bases = SyncBases::new();
414        sync_bases.insert_event_base(master.event_instance_id(), master.clone());
415
416        let diff = CalendarDiff::compute(
417            vec![local_master, local_override],
418            vec![RemoteEvent::new(master)],
419            &sync_bases,
420            &DateRange::default(),
421        );
422
423        assert_eq!(diff.outgoing, vec![EventChange::Create(override_event)]);
424        assert_eq!(diff.incoming, vec![]);
425    }
426
427    #[test]
428    fn orphan_rule_requires_synced_master_evidence() {
429        let (_tmp, calendar) = test_calendar();
430        let (_master, override_event) = recurring_series();
431        let local_override = calendar.create_event(override_event.clone()).unwrap();
432
433        let diff = CalendarDiff::compute(
434            vec![local_override],
435            vec![],
436            &SyncBases::new(),
437            &DateRange::default(),
438        );
439
440        assert_eq!(diff.outgoing, vec![EventChange::Create(override_event)]);
441        assert_eq!(diff.incoming, vec![]);
442    }
443
444    #[test]
445    fn orphan_rule_is_conservative_when_local_master_is_out_of_range() {
446        let (_tmp, calendar) = test_calendar();
447        let (mut master, mut override_event) = recurring_series();
448        master.recurrence = Some(Recurrence::new("FREQ=DAILY;COUNT=2"));
449        override_event.start = EventTime::DateTimeFloating(
450            chrono::NaiveDate::from_ymd_opt(2027, 1, 1)
451                .unwrap()
452                .and_hms_opt(12, 0, 0)
453                .unwrap(),
454        );
455
456        let local_master = calendar.create_event(master.clone()).unwrap();
457        let local_override = calendar.create_event(override_event.clone()).unwrap();
458
459        let mut sync_bases = SyncBases::new();
460        sync_bases.insert_event_base(master.event_instance_id(), master);
461
462        let range = DateRange {
463            from: Some(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()),
464            to: Some(Utc.with_ymd_and_hms(2027, 1, 2, 0, 0, 0).unwrap()),
465        };
466        let diff = CalendarDiff::compute(
467            vec![local_master, local_override],
468            vec![],
469            &sync_bases,
470            &range,
471        );
472
473        assert_eq!(diff.outgoing, vec![EventChange::Create(override_event)]);
474        assert_eq!(diff.incoming, vec![]);
475    }
476
477    #[test]
478    fn deleted_local_event_becomes_outgoing_delete() {
479        let remote_event = test_event();
480
481        let mut synced_ids = SyncBases::new();
482        synced_ids.insert_known_event_id(remote_event.event_instance_id());
483
484        let local_events = vec![];
485        let remote_events = vec![RemoteEvent::new(remote_event.clone())];
486
487        let diff = CalendarDiff::compute(
488            local_events,
489            remote_events,
490            &synced_ids,
491            &DateRange::default(),
492        );
493
494        assert_eq!(diff.outgoing, vec![EventChange::Delete(remote_event)]);
495        assert_eq!(diff.incoming, vec![]);
496    }
497
498    #[test]
499    fn invitee_seeing_cancellation_gets_status_flip_update() {
500        // Active local + cancelled remote (same UID) → pull the status flip
501        // so the user sees the cancellation in their calendar.
502        let (_tmp, calendar) = test_calendar();
503        let event = test_event();
504        let calendar_event = calendar.create_event(event.clone()).unwrap();
505
506        let mut cancelled = event.clone();
507        cancelled.status = Status::Cancelled;
508        // Cancellation happens on the provider side, so the remote LAST-MODIFIED
509        // is fresher than the local file's mtime — pull direction. Far-future
510        // timestamp keeps the test deterministic vs. the just-written tempfile.
511        cancelled.last_modified = Some(Utc.with_ymd_and_hms(3000, 1, 1, 0, 0, 0).unwrap());
512
513        let diff = CalendarDiff::compute(
514            vec![calendar_event],
515            vec![RemoteEvent::new(cancelled.clone())],
516            &SyncBases::new(),
517            &DateRange::default(),
518        );
519
520        assert_eq!(diff.outgoing, vec![]);
521        assert_eq!(
522            diff.incoming,
523            vec![EventChange::Update {
524                from: event,
525                to: cancelled,
526            }]
527        );
528    }
529
530    #[test]
531    fn missing_local_cancelled_remote_is_skipped() {
532        // No local file + remote cancelled tombstone → already in sync.
533        // Prevents spurious "outgoing delete" churn and stops cancelled
534        // tombstones from being re-created on pull.
535        let mut cancelled = test_event();
536        cancelled.status = Status::Cancelled;
537
538        let mut synced_ids = SyncBases::new();
539        synced_ids.insert_known_event_id(cancelled.event_instance_id());
540
541        let diff = CalendarDiff::compute(
542            vec![],
543            vec![RemoteEvent::new(cancelled)],
544            &synced_ids,
545            &DateRange::default(),
546        );
547
548        assert_eq!(diff.outgoing, vec![]);
549        assert_eq!(diff.incoming, vec![]);
550    }
551
552    #[test]
553    fn cancelled_instance_of_local_master_becomes_incoming_create() {
554        let (_tmp, calendar) = test_calendar();
555        let mut master = test_event();
556        master.recurrence = Some(Recurrence::new("FREQ=DAILY"));
557        let calendar_event = calendar.create_event(master.clone()).unwrap();
558
559        let mut cancelled_instance = master.clone();
560        cancelled_instance.recurrence = None;
561        cancelled_instance.recurrence_id =
562            Some(RecurrenceId::from_event_time(master.start.clone()));
563        cancelled_instance.status = Status::Cancelled;
564
565        let diff = CalendarDiff::compute(
566            vec![calendar_event],
567            vec![
568                RemoteEvent::new(master),
569                RemoteEvent::new(cancelled_instance.clone()),
570            ],
571            &SyncBases::new(),
572            &DateRange::default(),
573        );
574
575        assert_eq!(diff.outgoing, vec![]);
576        assert_eq!(diff.incoming, vec![EventChange::Create(cancelled_instance)]);
577    }
578
579    #[test]
580    fn first_sync_pulls_master_and_cancelled_instance() {
581        let mut master = test_event();
582        master.recurrence = Some(Recurrence::new("FREQ=DAILY"));
583
584        let mut cancelled_instance = master.clone();
585        cancelled_instance.recurrence = None;
586        cancelled_instance.recurrence_id =
587            Some(RecurrenceId::from_event_time(master.start.clone()));
588        cancelled_instance.status = Status::Cancelled;
589
590        let diff = CalendarDiff::compute(
591            vec![],
592            vec![
593                RemoteEvent::new(master.clone()),
594                RemoteEvent::new(cancelled_instance.clone()),
595            ],
596            &SyncBases::new(),
597            &DateRange::default(),
598        );
599
600        assert_eq!(diff.outgoing, vec![]);
601        assert_eq!(
602            diff.incoming,
603            vec![
604                EventChange::Create(master),
605                EventChange::Create(cancelled_instance),
606            ]
607        );
608    }
609
610    #[test]
611    fn cancelled_instance_is_skipped_when_local_master_was_deleted() {
612        let mut master = test_event();
613        master.recurrence = Some(Recurrence::new("FREQ=DAILY"));
614
615        let mut cancelled_instance = master.clone();
616        cancelled_instance.recurrence = None;
617        cancelled_instance.recurrence_id =
618            Some(RecurrenceId::from_event_time(master.start.clone()));
619        cancelled_instance.status = Status::Cancelled;
620
621        let mut sync_bases = SyncBases::new();
622        sync_bases.insert_known_event_id(master.event_instance_id());
623
624        let diff = CalendarDiff::compute(
625            vec![],
626            vec![
627                RemoteEvent::new(master.clone()),
628                RemoteEvent::new(cancelled_instance),
629            ],
630            &sync_bases,
631            &DateRange::default(),
632        );
633
634        assert_eq!(diff.outgoing, vec![EventChange::Delete(master)]);
635        assert_eq!(diff.incoming, vec![]);
636    }
637
638    #[test]
639    fn cancelled_instance_is_skipped_when_remote_master_is_cancelled() {
640        let mut master = test_event();
641        master.recurrence = Some(Recurrence::new("FREQ=DAILY"));
642        master.status = Status::Cancelled;
643
644        let mut cancelled_instance = master.clone();
645        cancelled_instance.recurrence = None;
646        cancelled_instance.recurrence_id =
647            Some(RecurrenceId::from_event_time(master.start.clone()));
648
649        let diff = CalendarDiff::compute(
650            vec![],
651            vec![
652                RemoteEvent::new(master),
653                RemoteEvent::new(cancelled_instance),
654            ],
655            &SyncBases::new(),
656            &DateRange::default(),
657        );
658
659        assert_eq!(diff.outgoing, vec![]);
660        assert_eq!(diff.incoming, vec![]);
661    }
662
663    #[test]
664    fn both_cancelled_with_field_drift_is_skipped() {
665        // Same UID, both cancelled, but local has stripped data and remote
666        // has full data. Don't surface this as churn — cancelled events are
667        // historical record, the field-shape drift doesn't matter.
668        let (_tmp, calendar) = test_calendar();
669        let mut local = test_event();
670        local.status = Status::Cancelled;
671        let calendar_event = calendar.create_event(local.clone()).unwrap();
672
673        let mut remote = local.clone();
674        remote.summary = Some("Now with extra data".into());
675        remote.location = Some("Somewhere".into());
676
677        let diff = CalendarDiff::compute(
678            vec![calendar_event],
679            vec![RemoteEvent::new(remote)],
680            &SyncBases::new(),
681            &DateRange::default(),
682        );
683
684        assert_eq!(diff.outgoing, vec![]);
685        assert_eq!(diff.incoming, vec![]);
686    }
687
688    #[test]
689    fn expired_recurring_event_not_flagged_as_delete() {
690        // Expired recurring event (UNTIL in the past) has no occurrences in
691        // the sync window, but it's not deleted — don't flag it as one.
692        let (_tmp, calendar) = test_calendar();
693
694        let mut event = Event::new(
695            "Old recurring",
696            EventTime::Date(chrono::NaiveDate::from_ymd_opt(1968, 5, 8).unwrap()),
697        );
698        event.recurrence = Some(crate::event::Recurrence::new(
699            "FREQ=YEARLY;UNTIL=20080507T120000Z",
700        ));
701        let calendar_event = calendar.create_event(event.clone()).unwrap();
702
703        let mut synced_ids = SyncBases::new();
704        synced_ids.insert_known_event_id(event.event_instance_id());
705
706        let range = DateRange {
707            from: Some(Utc.with_ymd_and_hms(2025, 5, 14, 0, 0, 0).unwrap()),
708            to: Some(Utc.with_ymd_and_hms(2027, 5, 14, 0, 0, 0).unwrap()),
709        };
710
711        let diff = CalendarDiff::compute(vec![calendar_event], vec![], &synced_ids, &range);
712
713        assert_eq!(diff.outgoing, vec![]);
714        assert_eq!(diff.incoming, vec![]);
715    }
716
717    #[test]
718    fn deleted_remote_event_becomes_incoming_delete() {
719        let (_tmp, calendar_event) = test_calendar_event();
720        let local_event = calendar_event.event().clone();
721
722        let mut synced_ids = SyncBases::new();
723        synced_ids.insert_known_event_id(local_event.event_instance_id());
724
725        let local_events = vec![calendar_event];
726        let remote_events = vec![];
727
728        let diff = CalendarDiff::compute(
729            local_events,
730            remote_events,
731            &synced_ids,
732            &DateRange::default(),
733        );
734
735        assert_eq!(diff.outgoing, vec![]);
736        assert_eq!(diff.incoming, vec![EventChange::Delete(local_event)]);
737    }
738
739    #[test]
740    fn event_base_marks_event_as_previously_synced() {
741        let (_tmp, calendar_event) = test_calendar_event();
742        let local_event = calendar_event.event().clone();
743        let id = local_event.event_instance_id();
744
745        let mut sync_bases = SyncBases::new();
746        sync_bases.insert_event_base(id, local_event.clone());
747
748        let diff = CalendarDiff::compute(
749            vec![calendar_event],
750            vec![],
751            &sync_bases,
752            &DateRange::default(),
753        );
754
755        assert_eq!(diff.outgoing, vec![]);
756        assert_eq!(diff.incoming, vec![EventChange::Delete(local_event)]);
757    }
758
759    #[test]
760    fn updated_local_event_becomes_outgoing_update() {
761        let (_tmp, calendar) = test_calendar();
762        let remote_event = test_event();
763
764        let mut local_event = remote_event.clone();
765        local_event.summary = Some("Updated Test Event".to_string());
766        let calendar_event = calendar.create_event(local_event.clone()).unwrap();
767
768        let mut synced_ids = SyncBases::new();
769        synced_ids.insert_known_event_id(remote_event.event_instance_id());
770
771        let local_events = vec![calendar_event];
772        let remote_events = vec![RemoteEvent::new(remote_event.clone())];
773
774        let diff = CalendarDiff::compute(
775            local_events,
776            remote_events,
777            &synced_ids,
778            &DateRange::default(),
779        );
780
781        assert_eq!(
782            diff.outgoing,
783            vec![EventChange::Update {
784                from: remote_event,
785                to: local_event,
786            }]
787        );
788        assert_eq!(diff.incoming, vec![]);
789    }
790
791    #[test]
792    fn higher_remote_sequence_breaks_conflict_without_modified_time() {
793        let (_tmp, calendar) = test_calendar();
794        let mut base = test_event();
795        base.sequence = 1;
796
797        let mut local = base.clone();
798        local.summary = Some("Edited locally".to_string());
799        local.sequence = 2;
800        let calendar_event = calendar.create_event(local.clone()).unwrap();
801
802        let mut remote = base.clone();
803        remote.summary = Some("Edited remotely".to_string());
804        remote.sequence = 3;
805        remote.last_modified = None;
806
807        let mut sync_bases = SyncBases::new();
808        sync_bases.insert_event_base(base.event_instance_id(), base);
809
810        let diff = CalendarDiff::compute(
811            vec![calendar_event],
812            vec![RemoteEvent::new(remote.clone())],
813            &sync_bases,
814            &DateRange::default(),
815        );
816
817        assert_eq!(diff.outgoing, vec![]);
818        assert_eq!(
819            diff.incoming,
820            vec![EventChange::Update {
821                from: local,
822                to: remote,
823            }]
824        );
825    }
826
827    #[test]
828    fn remote_update_without_modified_time_is_pulled_when_local_matches_base() {
829        let (_tmp, calendar) = test_calendar();
830        let base = test_event();
831        let calendar_event = calendar.create_event(base.clone()).unwrap();
832        assert!(calendar_event.modified_at().is_some());
833
834        let mut remote = base.clone();
835        remote.summary = Some("Edited remotely".to_string());
836        remote.last_modified = None;
837
838        let mut sync_bases = SyncBases::new();
839        sync_bases.insert_event_base(base.event_instance_id(), base.clone());
840
841        let diff = CalendarDiff::compute(
842            vec![calendar_event],
843            vec![RemoteEvent::new(remote.clone())],
844            &sync_bases,
845            &DateRange::default(),
846        );
847
848        assert_eq!(diff.outgoing, vec![]);
849        assert_eq!(
850            diff.incoming,
851            vec![EventChange::Update {
852                from: base,
853                to: remote,
854            }]
855        );
856    }
857
858    #[test]
859    fn remote_update_is_pulled_when_unchanged_local_file_has_newer_mtime() {
860        let (_tmp, calendar) = test_calendar();
861        let mut local = test_event();
862        local.last_modified = Some(Utc.with_ymd_and_hms(2026, 3, 5, 8, 45, 16).unwrap());
863        let calendar_event = calendar.create_event(local.clone()).unwrap();
864
865        let mut base = local.clone();
866        base.last_modified = None;
867
868        let mut remote = base.clone();
869        remote.summary = Some("Edited remotely".to_string());
870        remote.last_modified = Some(Utc.with_ymd_and_hms(2025, 6, 9, 10, 42, 20).unwrap());
871
872        assert!(calendar_event.modified_at() > remote.last_modified);
873
874        let mut sync_bases = SyncBases::new();
875        sync_bases.insert_event_base(base.event_instance_id(), base.clone());
876
877        let diff = CalendarDiff::compute(
878            vec![calendar_event],
879            vec![RemoteEvent::new(remote.clone())],
880            &sync_bases,
881            &DateRange::default(),
882        );
883
884        assert_eq!(diff.outgoing, vec![]);
885        assert_eq!(
886            diff.incoming,
887            vec![EventChange::Update {
888                from: local,
889                to: remote,
890            }]
891        );
892    }
893
894    #[test]
895    fn local_update_is_pushed_when_unchanged_remote_has_newer_modified_time() {
896        let (_tmp, calendar) = test_calendar();
897        let base = test_event();
898
899        let mut local = base.clone();
900        local.summary = Some("Edited locally".to_string());
901        local.last_modified = Some(Utc.with_ymd_and_hms(2025, 6, 9, 10, 42, 20).unwrap());
902        let calendar_event = calendar.create_event(local.clone()).unwrap();
903
904        let mut remote = base.clone();
905        remote.last_modified = Some(Utc.with_ymd_and_hms(2026, 3, 5, 8, 45, 16).unwrap());
906        assert!(calendar_event.modified_at() < remote.last_modified);
907
908        let mut sync_bases = SyncBases::new();
909        sync_bases.insert_event_base(base.event_instance_id(), base);
910
911        let diff = CalendarDiff::compute(
912            vec![calendar_event],
913            vec![RemoteEvent::new(remote.clone())],
914            &sync_bases,
915            &DateRange::default(),
916        );
917
918        assert_eq!(diff.incoming, vec![]);
919        assert_eq!(
920            diff.outgoing,
921            vec![EventChange::Update {
922                from: remote,
923                to: local,
924            }]
925        );
926    }
927
928    #[test]
929    fn sync_metadata_only_differences_produce_no_diff() {
930        // Differences confined to LAST-MODIFIED and SEQUENCE are sync noise —
931        // they shouldn't surface as pending updates.
932        let (_tmp, calendar) = test_calendar();
933        let local_event = test_event();
934
935        let mut remote_event = local_event.clone();
936        remote_event.last_modified = Some(Utc::now() + chrono::Duration::days(1));
937        remote_event.sequence += 1;
938
939        let calendar_event = calendar.create_event(local_event.clone()).unwrap();
940
941        let mut synced_ids = SyncBases::new();
942        synced_ids.insert_known_event_id(local_event.event_instance_id());
943
944        let diff = CalendarDiff::compute(
945            vec![calendar_event],
946            vec![RemoteEvent::new(remote_event)],
947            &synced_ids,
948            &DateRange::default(),
949        );
950
951        assert_eq!(diff.outgoing, vec![]);
952        assert_eq!(diff.incoming, vec![]);
953    }
954
955    #[test]
956    fn equal_timestamps_with_field_drift_resolve_to_pull() {
957        // Regression: when local mtime equals remote LAST-MODIFIED but the
958        // events still differ on some field, the difference reflects a
959        // schema/parser change (the local file pre-dates our support for
960        // some property), not a local edit. Direction must be pull so the
961        // remote authoritative value refreshes the field we now understand.
962        let (_tmp, calendar) = test_calendar();
963        let when = Utc.with_ymd_and_hms(2026, 5, 15, 13, 12, 52).unwrap();
964
965        let mut local_event = test_event();
966        local_event.last_modified = Some(when);
967        let calendar_event = calendar.create_event(local_event.clone()).unwrap();
968
969        let mut remote_event = local_event.clone();
970        remote_event.visibility = Some(crate::event::Visibility::Private);
971
972        let mut synced_ids = SyncBases::new();
973        synced_ids.insert_known_event_id(local_event.event_instance_id());
974
975        let diff = CalendarDiff::compute(
976            vec![calendar_event],
977            vec![RemoteEvent::new(remote_event.clone())],
978            &synced_ids,
979            &DateRange::default(),
980        );
981
982        assert_eq!(diff.outgoing, vec![]);
983        assert_eq!(
984            diff.incoming,
985            vec![EventChange::Update {
986                from: local_event,
987                to: remote_event,
988            }]
989        );
990    }
991
992    #[test]
993    fn unspecified_local_visibility_resolves_to_pull_even_when_local_mtime_is_newer() {
994        // Regression: a local file predating CLASS support has visibility: None.
995        // Even when its mtime is newer than the remote LAST-MODIFIED (e.g. after
996        // a bulk re-serialization bumped every file's mtime), an unspecified
997        // visibility must not originate a push that overwrites the remote's real
998        // value — it resolves to a pull instead.
999        let (_tmp, calendar) = test_calendar();
1000
1001        let mut local_event = test_event();
1002        local_event.last_modified = Some(Utc.with_ymd_and_hms(2026, 3, 5, 8, 45, 16).unwrap());
1003        assert_eq!(local_event.visibility, None);
1004        let calendar_event = calendar.create_event(local_event.clone()).unwrap();
1005
1006        let mut remote_event = local_event.clone();
1007        remote_event.visibility = Some(crate::event::Visibility::Private);
1008        remote_event.last_modified = Some(Utc.with_ymd_and_hms(2025, 6, 9, 10, 42, 20).unwrap());
1009
1010        let mut synced_ids = SyncBases::new();
1011        synced_ids.insert_known_event_id(local_event.event_instance_id());
1012
1013        let diff = CalendarDiff::compute(
1014            vec![calendar_event],
1015            vec![RemoteEvent::new(remote_event.clone())],
1016            &synced_ids,
1017            &DateRange::default(),
1018        );
1019
1020        assert_eq!(diff.outgoing, vec![]);
1021        assert_eq!(
1022            diff.incoming,
1023            vec![EventChange::Update {
1024                from: local_event,
1025                to: remote_event,
1026            }]
1027        );
1028    }
1029
1030    #[test]
1031    fn outgoing_update_keeps_remote_visibility_when_local_is_unspecified() {
1032        // A genuine local edit (summary) still pushes, but it must not drag an
1033        // unspecified visibility along: the pushed event keeps the remote's
1034        // visibility rather than dropping it to the default.
1035        let (_tmp, calendar) = test_calendar();
1036
1037        let mut local_event = test_event();
1038        local_event.summary = Some("Edited locally".to_string());
1039        local_event.last_modified = Some(Utc.with_ymd_and_hms(2026, 3, 5, 8, 45, 16).unwrap());
1040        let calendar_event = calendar.create_event(local_event.clone()).unwrap();
1041
1042        let mut remote_event = local_event.clone();
1043        remote_event.summary = Some("Test Event".to_string());
1044        remote_event.visibility = Some(crate::event::Visibility::Private);
1045        remote_event.last_modified = Some(Utc.with_ymd_and_hms(2025, 6, 9, 10, 42, 20).unwrap());
1046
1047        let mut synced_ids = SyncBases::new();
1048        synced_ids.insert_known_event_id(local_event.event_instance_id());
1049
1050        let diff = CalendarDiff::compute(
1051            vec![calendar_event],
1052            vec![RemoteEvent::new(remote_event)],
1053            &synced_ids,
1054            &DateRange::default(),
1055        );
1056
1057        assert_eq!(diff.incoming, vec![]);
1058        match diff.outgoing.as_slice() {
1059            [EventChange::Update { to, .. }] => {
1060                assert_eq!(to.summary.as_deref(), Some("Edited locally"));
1061                assert_eq!(to.visibility, Some(crate::event::Visibility::Private));
1062            }
1063            other => panic!("expected one outgoing Update, got {other:?}"),
1064        }
1065    }
1066
1067    #[test]
1068    fn x_property_change_surfaces_as_diff() {
1069        let (_tmp, calendar) = test_calendar();
1070        let mut local_event = test_event();
1071        local_event
1072            .x_properties
1073            .push(crate::event::XProperty::new("X-GOOGLE-COLOR-ID", "5"));
1074
1075        let mut remote_event = local_event.clone();
1076        remote_event.x_properties = vec![crate::event::XProperty::new("X-GOOGLE-COLOR-ID", "9")];
1077        remote_event.last_modified = Some(Utc::now() + chrono::Duration::days(1));
1078
1079        let calendar_event = calendar.create_event(local_event.clone()).unwrap();
1080
1081        let mut synced_ids = SyncBases::new();
1082        synced_ids.insert_known_event_id(local_event.event_instance_id());
1083
1084        let diff = CalendarDiff::compute(
1085            vec![calendar_event],
1086            vec![RemoteEvent::new(remote_event.clone())],
1087            &synced_ids,
1088            &DateRange::default(),
1089        );
1090
1091        assert_eq!(diff.outgoing, vec![]);
1092        assert_eq!(
1093            diff.incoming,
1094            vec![EventChange::Update {
1095                from: local_event,
1096                to: remote_event,
1097            }]
1098        );
1099    }
1100
1101    #[test]
1102    fn x_properties_compare_equal_regardless_of_order() {
1103        // Local files come from BTreeMap-backed ICS parsing (alphabetical),
1104        // but providers like Google build x_properties in insertion order.
1105        // Same content in different order must compare equal.
1106        let mut event_a = test_event();
1107        event_a.x_properties = vec![
1108            crate::event::XProperty::new("X-GOOGLE-EVENT-ID", "abc"),
1109            crate::event::XProperty::new("X-GOOGLE-COLOR-ID", "5"),
1110        ];
1111
1112        let mut event_b = event_a.clone();
1113        event_b.x_properties = vec![
1114            crate::event::XProperty::new("X-GOOGLE-COLOR-ID", "5"),
1115            crate::event::XProperty::new("X-GOOGLE-EVENT-ID", "abc"),
1116        ];
1117
1118        assert_eq!(event_a, event_b);
1119    }
1120
1121    #[test]
1122    fn updated_remote_event_becomes_incoming_update() {
1123        let (_tmp, calendar) = test_calendar();
1124        let local_event = test_event();
1125
1126        let mut remote_event = local_event.clone();
1127        remote_event.summary = Some("Updated Test Event".to_string());
1128        remote_event.last_modified = Some(Utc::now() + chrono::Duration::days(1));
1129
1130        let calendar_event = calendar.create_event(local_event.clone()).unwrap();
1131
1132        let mut synced_ids = SyncBases::new();
1133        synced_ids.insert_known_event_id(local_event.event_instance_id());
1134
1135        let local_events = vec![calendar_event];
1136        let remote_events = vec![RemoteEvent::new(remote_event.clone())];
1137
1138        let diff = CalendarDiff::compute(
1139            local_events,
1140            remote_events,
1141            &synced_ids,
1142            &DateRange::default(),
1143        );
1144
1145        assert_eq!(diff.outgoing, vec![]);
1146        assert_eq!(
1147            diff.incoming,
1148            vec![EventChange::Update {
1149                from: local_event,
1150                to: remote_event,
1151            }]
1152        );
1153    }
1154
1155    #[test]
1156    fn local_event_outside_window_is_not_flagged_for_delete() {
1157        // Spec: events outside the queried window are left untouched even
1158        // when their IDs sit in synced_ids and the remote response is empty.
1159        let (_tmp, calendar) = test_calendar();
1160
1161        let mut old_event = test_event();
1162        old_event.start =
1163            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2020, 1, 1, 9, 0, 0).unwrap());
1164        old_event.end = Some(EventTime::DateTimeUtc(
1165            Utc.with_ymd_and_hms(2020, 1, 1, 10, 0, 0).unwrap(),
1166        ));
1167        let calendar_event = calendar.create_event(old_event.clone()).unwrap();
1168
1169        let mut synced_ids = SyncBases::new();
1170        synced_ids.insert_known_event_id(old_event.event_instance_id());
1171
1172        let range = DateRange {
1173            from: Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()),
1174            to: Some(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()),
1175        };
1176
1177        let diff = CalendarDiff::compute(vec![calendar_event], vec![], &synced_ids, &range);
1178
1179        assert_eq!(diff.outgoing, vec![]);
1180        assert_eq!(diff.incoming, vec![]);
1181    }
1182
1183    #[test]
1184    fn local_event_inside_window_is_flagged_for_delete_when_remote_drops_it() {
1185        let (_tmp, calendar) = test_calendar();
1186
1187        let mut event = test_event();
1188        event.start = EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 6, 15, 9, 0, 0).unwrap());
1189        event.end = Some(EventTime::DateTimeUtc(
1190            Utc.with_ymd_and_hms(2026, 6, 15, 10, 0, 0).unwrap(),
1191        ));
1192        let calendar_event = calendar.create_event(event.clone()).unwrap();
1193
1194        let mut synced_ids = SyncBases::new();
1195        synced_ids.insert_known_event_id(event.event_instance_id());
1196
1197        let range = DateRange {
1198            from: Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()),
1199            to: Some(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()),
1200        };
1201
1202        let diff = CalendarDiff::compute(vec![calendar_event], vec![], &synced_ids, &range);
1203
1204        assert_eq!(diff.outgoing, vec![]);
1205        assert_eq!(diff.incoming, vec![EventChange::Delete(event)]);
1206    }
1207
1208    #[test]
1209    fn one_sided_window_leaves_out_of_range_local_events_untouched() {
1210        let boundary = Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap();
1211        for (year, range) in [
1212            (
1213                2028,
1214                DateRange {
1215                    from: None,
1216                    to: Some(boundary),
1217                },
1218            ),
1219            (
1220                2026,
1221                DateRange {
1222                    from: Some(boundary),
1223                    to: None,
1224                },
1225            ),
1226        ] {
1227            for synced in [false, true] {
1228                for recurring in [false, true] {
1229                    let (_tmp, calendar) = test_calendar();
1230                    let mut event = test_event();
1231                    event.start = EventTime::DateTimeUtc(
1232                        Utc.with_ymd_and_hms(year, 2, 25, 15, 45, 0).unwrap(),
1233                    );
1234                    event.end = None;
1235                    if recurring {
1236                        event.recurrence = Some(Recurrence::new("FREQ=DAILY;COUNT=2"));
1237                    }
1238                    let local = calendar.create_event(event.clone()).unwrap();
1239                    let mut bases = SyncBases::new();
1240                    if synced {
1241                        bases.insert_known_event_id(event.event_instance_id());
1242                    }
1243
1244                    let diff = CalendarDiff::compute(vec![local], vec![], &bases, &range);
1245                    assert!(
1246                        diff.is_empty(),
1247                        "year={year}, synced={synced}, recurring={recurring}"
1248                    );
1249                }
1250            }
1251        }
1252    }
1253
1254    #[test]
1255    fn one_sided_window_still_detects_remote_deletions_in_range() {
1256        for range in [
1257            DateRange {
1258                from: None,
1259                to: Some(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()),
1260            },
1261            DateRange {
1262                from: Some(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()),
1263                to: None,
1264            },
1265        ] {
1266            let (_tmp, local) = test_calendar_event();
1267            let event = local.event().clone();
1268            let mut bases = SyncBases::new();
1269            bases.insert_known_event_id(event.event_instance_id());
1270
1271            let diff = CalendarDiff::compute(vec![local], vec![], &bases, &range);
1272            assert_eq!(diff.incoming, vec![EventChange::Delete(event)]);
1273            assert!(diff.outgoing.is_empty());
1274        }
1275    }
1276
1277    #[test]
1278    fn one_sided_window_does_not_treat_out_of_range_master_as_deleted() {
1279        for (year, range) in [
1280            (
1281                2025,
1282                DateRange {
1283                    from: None,
1284                    to: Some(Utc.with_ymd_and_hms(2025, 12, 31, 0, 0, 0).unwrap()),
1285                },
1286            ),
1287            (
1288                2027,
1289                DateRange {
1290                    from: Some(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()),
1291                    to: None,
1292                },
1293            ),
1294        ] {
1295            let (_tmp, calendar) = test_calendar();
1296            let (mut master, mut override_event) = recurring_series();
1297            master.recurrence = Some(Recurrence::new("FREQ=DAILY;COUNT=2"));
1298            override_event.start =
1299                EventTime::DateTimeUtc(Utc.with_ymd_and_hms(year, 6, 1, 12, 0, 0).unwrap());
1300            override_event.end = None;
1301            let local_master = calendar.create_event(master.clone()).unwrap();
1302            let local_override = calendar.create_event(override_event.clone()).unwrap();
1303            let mut bases = SyncBases::new();
1304            bases.insert_event_base(master.event_instance_id(), master);
1305
1306            let diff =
1307                CalendarDiff::compute(vec![local_master, local_override], vec![], &bases, &range);
1308            assert!(diff.incoming.is_empty());
1309            assert_eq!(diff.outgoing, vec![EventChange::Create(override_event)]);
1310        }
1311    }
1312}