Skip to main content

caldir_core/
connection.rs

1mod error;
2
3use std::collections::HashMap;
4use std::collections::hash_map::Entry;
5
6use crate::calendar::{CalendarError, SyncBases};
7use crate::diff::EventChange;
8use crate::event::EventInstanceId;
9use crate::{Calendar, CalendarDiff, CalendarEvent, DateRange, Event, Remote, RemoteEvent};
10pub use error::ConnectionError;
11
12/// A connection is a [local calendar] + [remote calendar] pair
13pub struct Connection {
14    local: Calendar,
15    remote: Remote,
16}
17
18impl Connection {
19    pub fn new(local: Calendar, remote: Remote) -> Self {
20        Self { local, remote }
21    }
22
23    pub fn local(&self) -> &Calendar {
24        &self.local
25    }
26
27    pub fn remote(&self) -> &Remote {
28        &self.remote
29    }
30
31    pub fn read_only(&self) -> bool {
32        self.local
33            .config()
34            .and_then(|c| c.read_only())
35            .unwrap_or(false)
36    }
37
38    pub async fn diff(&mut self, range: &DateRange) -> Result<CalendarDiff, ConnectionError> {
39        let local_events = self.local().events()?;
40        let remote_events = self.remote().list_events(range).await?;
41
42        // State migration: in-sync pairs never produce a change to apply, so
43        // this is the only place their base can be recorded. Without it,
44        // legacy known-id entries would sit on the mtime fallback forever.
45        let backfill = bases_to_backfill(
46            &local_events,
47            &remote_events,
48            self.local.state().sync_bases(),
49        );
50        if !backfill.is_empty() {
51            self.local.record_sync_bases(backfill)?;
52        }
53
54        let sync_bases = self.local().state().sync_bases();
55
56        let mut diff = CalendarDiff::compute(local_events, remote_events, sync_bases, range);
57
58        if self.read_only() {
59            diff.discard_outgoing();
60        }
61
62        Ok(diff)
63    }
64
65    // pull
66    pub fn apply_incoming_diff(&mut self, diff: &CalendarDiff) -> Result<(), ConnectionError> {
67        let mut events_by_instance_id: HashMap<EventInstanceId, CalendarEvent> = self
68            .local
69            .events()?
70            .into_iter()
71            .map(|e| (e.event().event_instance_id(), e))
72            .collect();
73
74        let mut sync_bases = Vec::new();
75
76        // Same partial-failure flush pattern as `apply_outgoing_diff`: a
77        // local-fs error mid-loop must not drop changes already applied to disk.
78        let loop_result = pull_incoming_changes(
79            &self.local,
80            diff,
81            &mut events_by_instance_id,
82            &mut sync_bases,
83        );
84
85        let record_result = self.local.record_sync_bases(sync_bases);
86
87        loop_result?;
88        record_result?;
89        Ok(())
90    }
91
92    // push
93    pub async fn apply_outgoing_diff(
94        &mut self,
95        diff: &CalendarDiff,
96    ) -> Result<(), ConnectionError> {
97        let mut events_by_instance_id: HashMap<EventInstanceId, CalendarEvent> = self
98            .local
99            .events()?
100            .into_iter()
101            .map(|e| (e.event().event_instance_id(), e))
102            .collect();
103
104        let mut sync_bases = Vec::new();
105
106        // Handles mid-loop errors gracefully
107        let loop_result = push_outgoing_changes(
108            &self.remote,
109            diff,
110            &mut events_by_instance_id,
111            &mut sync_bases,
112        )
113        .await;
114
115        let record_result = self.local.record_sync_bases(sync_bases);
116
117        loop_result?;
118        record_result?;
119        Ok(())
120    }
121
122    // discard
123    pub fn discard_outgoing_diff(&self, diff: &CalendarDiff) -> Result<(), ConnectionError> {
124        let mut events_by_instance_id: HashMap<EventInstanceId, CalendarEvent> = self
125            .local
126            .events()?
127            .into_iter()
128            .map(|e| (e.event().event_instance_id(), e))
129            .collect();
130
131        for change in diff.outgoing() {
132            match change {
133                EventChange::Create(event) => {
134                    if let Some(cal_event) =
135                        events_by_instance_id.remove(&event.event_instance_id())
136                    {
137                        cal_event.delete().map_err(CalendarError::from)?;
138                    }
139                }
140                EventChange::Update { from, to } => {
141                    if let Some(cal_event) = events_by_instance_id.get_mut(&to.event_instance_id())
142                    {
143                        cal_event
144                            .update(from.clone())
145                            .map_err(CalendarError::from)?;
146                    }
147                }
148                EventChange::Delete(event) => {
149                    let cal_event = self.local.create_event(event.clone())?;
150                    events_by_instance_id.insert(cal_event.event().event_instance_id(), cal_event);
151                }
152            }
153        }
154
155        Ok(())
156    }
157}
158
159/// Events present and identical on both sides whose base is missing (legacy
160/// known-id entry) or stale. Both sides agreeing *is* the base — record it.
161/// Pairs with no sync state at all are left alone: they were never synced,
162/// and recording a base would silently change their delete semantics.
163fn bases_to_backfill(
164    local_events: &[CalendarEvent],
165    remote_events: &[RemoteEvent],
166    sync_bases: &SyncBases,
167) -> Vec<Event> {
168    let local_by_id: HashMap<_, _> = local_events
169        .iter()
170        .map(|e| (e.event().event_instance_id(), e.event()))
171        .collect();
172
173    let mut backfill = Vec::new();
174
175    for remote in remote_events {
176        let remote = remote.event();
177        let id = remote.event_instance_id();
178
179        let in_sync = local_by_id.get(&id).is_some_and(|local| *local == remote);
180
181        let base_is_current = match sync_bases.get(&id) {
182            None => continue,
183            Some(Some(base)) => base.as_ref() == remote,
184            Some(None) => false,
185        };
186
187        if in_sync && !base_is_current {
188            backfill.push(remote.clone());
189        }
190    }
191
192    backfill
193}
194
195fn pull_incoming_changes(
196    local: &Calendar,
197    diff: &CalendarDiff,
198    events_by_instance_id: &mut HashMap<EventInstanceId, CalendarEvent>,
199    sync_bases: &mut Vec<Event>,
200) -> Result<(), ConnectionError> {
201    for change in diff.incoming() {
202        match change {
203            EventChange::Create(event) => {
204                // Another pull may have created this event since the diff was computed.
205                match events_by_instance_id.entry(event.event_instance_id()) {
206                    Entry::Vacant(slot) => {
207                        slot.insert(local.create_event(event.clone())?);
208                    }
209                    Entry::Occupied(slot) if slot.get().event() != event => continue,
210                    Entry::Occupied(_) => {}
211                }
212                // Record the base only when the local file matches the incoming event.
213                sync_bases.push(event.clone());
214            }
215            EventChange::Update { to, .. } => {
216                if let Some(cal_event) = events_by_instance_id.get_mut(&to.event_instance_id()) {
217                    cal_event.update(to.clone()).map_err(CalendarError::from)?;
218                }
219                sync_bases.push(to.clone());
220            }
221            EventChange::Delete(event) => {
222                if let Some(cal_event) = events_by_instance_id.remove(&event.event_instance_id()) {
223                    cal_event.delete().map_err(CalendarError::from)?;
224                }
225            }
226        }
227    }
228
229    Ok(())
230}
231
232async fn push_outgoing_changes(
233    remote: &Remote,
234    diff: &CalendarDiff,
235    events_by_instance_id: &mut HashMap<EventInstanceId, CalendarEvent>,
236    sync_bases: &mut Vec<Event>,
237) -> Result<(), ConnectionError> {
238    for change in diff.outgoing() {
239        if let Some(remote_event) = remote.apply_change(change).await? {
240            let returned_event = remote_event.event();
241
242            // Sometimes provider overwrite the event's UID:
243            let original_event_id = match change {
244                EventChange::Create(event) => event.event_instance_id(),
245                EventChange::Update { to, .. } => to.event_instance_id(),
246                EventChange::Delete(_) => unreachable!("apply_change returns None for Delete"),
247            };
248
249            if let Some(cal_event) = events_by_instance_id.get_mut(&original_event_id) {
250                cal_event
251                    .update(returned_event.clone())
252                    .map_err(CalendarError::from)?;
253            }
254
255            sync_bases.push(returned_event.clone());
256        }
257    }
258
259    Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::diff::EventChange;
266    use crate::event::{EventUid, Recurrence, XProperty};
267    use crate::provider::mock_provider::MockProvider;
268    use crate::test_utils::{
269        incoming_create_diff, incoming_delete_diff, incoming_update_diff, outgoing_create_diff,
270        outgoing_delete_diff, outgoing_update_diff, test_caldir, test_event, test_mock_provider,
271        test_remote_config, test_remote_params,
272    };
273    use crate::{CalendarConfig, rpc};
274    use pretty_assertions::assert_eq;
275
276    fn calendar_config(read_only: Option<bool>) -> CalendarConfig {
277        CalendarConfig::new(
278            Some("Read-only test".to_string()),
279            None,
280            read_only,
281            Some(test_remote_config("test-provider")),
282        )
283    }
284
285    fn writable_connection() -> (tempfile::TempDir, MockProvider, Connection) {
286        let (tmp, caldir) = test_caldir();
287        let calendar = caldir
288            .create_calendar("writable-cal", Some(calendar_config(Some(false))))
289            .unwrap();
290        let mock = test_mock_provider();
291        let remote = Remote::new(mock.provider(), test_remote_params());
292        let connection = Connection::new(calendar, remote);
293        (tmp, mock, connection)
294    }
295
296    #[tokio::test]
297    async fn diff_discards_outgoing_when_read_only() {
298        let (_tmp, caldir) = test_caldir();
299        let calendar = caldir
300            .create_calendar("read-only-cal", Some(calendar_config(Some(true))))
301            .unwrap();
302        calendar.create_event(test_event()).unwrap();
303
304        let mock = test_mock_provider();
305        mock.reply::<rpc::ListEvents>(vec![]);
306        let remote = Remote::new(mock.provider(), test_remote_params());
307
308        let mut connection = Connection::new(calendar, remote);
309        let diff = connection.diff(&DateRange::default()).await.unwrap();
310
311        assert!(
312            diff.outgoing().is_empty(),
313            "outgoing should be empty for read-only calendars, got {:?}",
314            diff.outgoing()
315        );
316    }
317
318    #[tokio::test]
319    async fn diff_includes_outgoing_when_not_read_only() {
320        let (_tmp, caldir) = test_caldir();
321        let calendar = caldir
322            .create_calendar("writable-cal", Some(calendar_config(Some(false))))
323            .unwrap();
324        let event = test_event();
325        calendar.create_event(event.clone()).unwrap();
326
327        let mock = test_mock_provider();
328        mock.reply::<rpc::ListEvents>(vec![]);
329        let remote = Remote::new(mock.provider(), test_remote_params());
330
331        let mut connection = Connection::new(calendar, remote);
332        let diff = connection.diff(&DateRange::default()).await.unwrap();
333
334        assert_eq!(diff.outgoing(), &[EventChange::Create(event)]);
335    }
336
337    #[tokio::test]
338    async fn diff_backfills_base_for_in_sync_legacy_known_id() {
339        let (_tmp, caldir) = test_caldir();
340        let calendar = caldir
341            .create_calendar("writable-cal", Some(calendar_config(Some(false))))
342            .unwrap();
343        let event = test_event();
344        let id = event.event_instance_id();
345        calendar.create_event(event.clone()).unwrap();
346
347        // Legacy state: id is known, but no base was ever recorded.
348        let state_dir = calendar.path().join(".caldir/state");
349        std::fs::create_dir_all(&state_dir).unwrap();
350        std::fs::write(state_dir.join("known_event_ids"), id.to_string()).unwrap();
351        let calendar = Calendar::load(calendar.path()).unwrap();
352        assert_eq!(calendar.state().sync_base(&id), None);
353
354        let mock = test_mock_provider();
355        mock.reply::<rpc::ListEvents>(vec![event.clone()]);
356        let remote = Remote::new(mock.provider(), test_remote_params());
357
358        let mut connection = Connection::new(calendar, remote);
359        let diff = connection.diff(&DateRange::default()).await.unwrap();
360
361        assert!(diff.is_empty());
362        let reloaded = Calendar::load(connection.local().path()).unwrap();
363        assert_eq!(reloaded.state().sync_base(&id), Some(&event));
364    }
365
366    #[tokio::test]
367    async fn diff_does_not_backfill_base_for_never_synced_pair() {
368        let (_tmp, mock, mut connection) = writable_connection();
369        let event = test_event();
370        let id = event.event_instance_id();
371        connection.local().create_event(event.clone()).unwrap();
372
373        mock.reply::<rpc::ListEvents>(vec![event]);
374        connection.diff(&DateRange::default()).await.unwrap();
375
376        let reloaded = Calendar::load(connection.local().path()).unwrap();
377        assert!(!reloaded.state().synced_event_ids().contains(&id));
378    }
379
380    #[tokio::test]
381    async fn deleted_remote_series_cleans_up_unsynced_local_override() {
382        let (_tmp, mock, mut connection) = writable_connection();
383        let mut master = test_event();
384        master.recurrence = Some(Recurrence::new("FREQ=DAILY"));
385        let mut override_event = master.occurrence_at(master.start.clone());
386        override_event.summary = Some("Overridden occurrence".to_string());
387
388        connection.local().create_event(master.clone()).unwrap();
389        connection
390            .local()
391            .create_event(override_event.clone())
392            .unwrap();
393        connection
394            .local
395            .record_sync_bases([master.clone()])
396            .unwrap();
397
398        mock.reply::<rpc::ListEvents>(vec![]);
399
400        let diff = connection.diff(&DateRange::default()).await.unwrap();
401        assert_eq!(diff.incoming().len(), 2);
402        assert!(diff.incoming().contains(&EventChange::Delete(master)));
403        assert!(
404            diff.incoming()
405                .contains(&EventChange::Delete(override_event))
406        );
407        assert!(diff.outgoing().is_empty());
408
409        connection.apply_incoming_diff(&diff).unwrap();
410        connection.apply_outgoing_diff(&diff).await.unwrap();
411
412        assert!(connection.local().events().unwrap().is_empty());
413        let _: rpc::ListEvents = mock.captured_request();
414    }
415
416    #[tokio::test]
417    async fn apply_incoming_diff_creates_file_for_incoming_create() {
418        let (_tmp, _mock, mut connection) = writable_connection();
419        let event = test_event();
420
421        connection
422            .apply_incoming_diff(&incoming_create_diff(event))
423            .unwrap();
424
425        let expected_path = connection
426            .local()
427            .path()
428            .join("2026-01-01T1200__test-event.ics");
429        assert!(expected_path.is_file());
430    }
431
432    #[tokio::test]
433    async fn apply_incoming_diff_does_not_recreate_existing_event() {
434        let (_tmp, _mock, mut connection) = writable_connection();
435        let event = test_event();
436        let diff = incoming_create_diff(event.clone());
437
438        connection.apply_incoming_diff(&diff).unwrap();
439        connection.apply_incoming_diff(&diff).unwrap();
440        let events = connection.local().events().unwrap();
441        assert_eq!(events.len(), 1);
442        assert_eq!(events[0].event(), &event);
443
444        // A stale create must also preserve edits made since the first pull.
445        let mut edited = event.clone();
446        edited.summary = Some("Locally edited".to_string());
447        let mut cal_event = connection.local().events().unwrap().pop().unwrap();
448        cal_event.update(edited.clone()).unwrap();
449
450        connection.apply_incoming_diff(&diff).unwrap();
451        let events = connection.local().events().unwrap();
452        assert_eq!(events.len(), 1);
453        assert_eq!(events[0].event(), &edited);
454        let reloaded = Calendar::load(connection.local().path()).unwrap();
455        assert_eq!(
456            reloaded.state().sync_base(&event.event_instance_id()),
457            Some(&event)
458        );
459    }
460
461    #[tokio::test]
462    async fn overlapping_creates_do_not_push_older_event_over_newer_remote() {
463        let (_tmp, mock, mut connection) = writable_connection();
464        let mut older = test_event();
465        older.last_modified = Some("2026-09-14T08:00:00Z".parse().unwrap());
466        let mut newer = older.clone();
467        newer.summary = Some("Newer remote title".to_string());
468        newer.last_modified = Some("2026-09-14T09:00:00Z".parse().unwrap());
469
470        // Both pulls computed creates before either wrote the local file.
471        let older_diff = incoming_create_diff(older.clone());
472        let newer_diff = incoming_create_diff(newer.clone());
473        connection.apply_incoming_diff(&older_diff).unwrap();
474        connection.apply_incoming_diff(&newer_diff).unwrap();
475
476        mock.reply::<rpc::ListEvents>(vec![newer.clone()]);
477        let next_diff = connection.diff(&DateRange::default()).await.unwrap();
478        assert!(next_diff.outgoing().is_empty());
479        assert_eq!(
480            next_diff.incoming(),
481            &[EventChange::Update {
482                from: older,
483                to: newer
484            }]
485        );
486    }
487
488    #[tokio::test]
489    async fn apply_incoming_diff_records_base_for_existing_unsynced_file() {
490        let (_tmp, _mock, mut connection) = writable_connection();
491        let event = test_event();
492        let id = event.event_instance_id();
493
494        // File written by another pull that never recorded its base.
495        connection.local().create_event(event.clone()).unwrap();
496        assert_eq!(connection.local().state().sync_base(&id), None);
497
498        connection
499            .apply_incoming_diff(&incoming_create_diff(event.clone()))
500            .unwrap();
501
502        assert_eq!(connection.local().events().unwrap().len(), 1);
503        let reloaded = Calendar::load(connection.local().path()).unwrap();
504        assert_eq!(reloaded.state().sync_base(&id), Some(&event));
505    }
506
507    #[tokio::test]
508    async fn apply_incoming_diff_updates_file_for_incoming_update() {
509        let (_tmp, _mock, mut connection) = writable_connection();
510        let from = test_event();
511        let cal_event = connection.local().create_event(from.clone()).unwrap();
512        let old_path = cal_event.path().to_path_buf();
513
514        let mut to = from.clone();
515        to.summary = Some("Updated Test Event".to_string());
516
517        connection
518            .apply_incoming_diff(&incoming_update_diff(from, to))
519            .unwrap();
520
521        let new_path = connection
522            .local()
523            .path()
524            .join("2026-01-01T1200__updated-test-event.ics");
525        assert!(new_path.is_file());
526        assert!(!old_path.exists());
527    }
528
529    #[tokio::test]
530    async fn apply_incoming_diff_deletes_file_for_incoming_delete() {
531        let (_tmp, _mock, mut connection) = writable_connection();
532        let event = test_event();
533        let cal_event = connection.local().create_event(event.clone()).unwrap();
534        let path = cal_event.path().to_path_buf();
535
536        connection
537            .apply_incoming_diff(&incoming_delete_diff(event))
538            .unwrap();
539
540        assert!(!path.exists());
541    }
542
543    #[tokio::test]
544    async fn apply_incoming_diff_records_incoming_create_in_state() {
545        let (_tmp, _mock, mut connection) = writable_connection();
546        let event = test_event();
547        let id = event.event_instance_id();
548
549        connection
550            .apply_incoming_diff(&incoming_create_diff(event))
551            .unwrap();
552
553        assert!(connection.local().state().synced_event_ids().contains(&id));
554    }
555
556    #[tokio::test]
557    async fn apply_incoming_diff_persists_base_to_disk() {
558        let (_tmp, _mock, mut connection) = writable_connection();
559        let event = test_event();
560        let id = event.event_instance_id();
561
562        connection
563            .apply_incoming_diff(&incoming_create_diff(event.clone()))
564            .unwrap();
565
566        let reloaded = Calendar::load(connection.local().path()).unwrap();
567        assert_eq!(reloaded.state().sync_base(&id), Some(&event));
568    }
569
570    #[tokio::test]
571    async fn apply_incoming_diff_does_not_record_deletes_in_state() {
572        let (_tmp, _mock, mut connection) = writable_connection();
573        let event = test_event();
574        let id = event.event_instance_id();
575        connection.local().create_event(event.clone()).unwrap();
576
577        connection
578            .apply_incoming_diff(&incoming_delete_diff(event))
579            .unwrap();
580
581        assert!(!connection.local().state().synced_event_ids().contains(&id));
582    }
583
584    #[tokio::test]
585    async fn apply_outgoing_diff_sends_create_event_for_outgoing_create() {
586        let (_tmp, mock, mut connection) = writable_connection();
587        let event = test_event();
588        connection.local().create_event(event.clone()).unwrap();
589
590        mock.reply::<rpc::CreateEvent>(event.clone());
591        connection
592            .apply_outgoing_diff(&outgoing_create_diff(event.clone()))
593            .await
594            .unwrap();
595
596        assert_eq!(mock.captured_request::<rpc::CreateEvent>().event, event);
597    }
598
599    #[tokio::test]
600    async fn apply_outgoing_diff_sends_update_event_for_outgoing_update() {
601        let (_tmp, mock, mut connection) = writable_connection();
602        let from = test_event();
603        let mut to = from.clone();
604        to.summary = Some("Updated".into());
605        connection.local().create_event(to.clone()).unwrap();
606
607        mock.reply::<rpc::UpdateEvent>(to.clone());
608        connection
609            .apply_outgoing_diff(&outgoing_update_diff(from, to.clone()))
610            .await
611            .unwrap();
612
613        assert_eq!(mock.captured_request::<rpc::UpdateEvent>().event, to);
614    }
615
616    #[tokio::test]
617    async fn apply_outgoing_diff_sends_delete_event_for_outgoing_delete() {
618        let (_tmp, mock, mut connection) = writable_connection();
619        let event = test_event();
620
621        mock.reply::<rpc::DeleteEvent>(());
622        connection
623            .apply_outgoing_diff(&outgoing_delete_diff(event.clone()))
624            .await
625            .unwrap();
626
627        assert_eq!(mock.captured_request::<rpc::DeleteEvent>().event, event);
628    }
629
630    #[tokio::test]
631    async fn apply_outgoing_diff_rewrites_local_with_canonical_event_from_provider() {
632        let (_tmp, mock, mut connection) = writable_connection();
633        let local = test_event();
634        connection.local().create_event(local.clone()).unwrap();
635
636        let mut canonical = local.clone();
637        canonical.x_properties.push(XProperty::new(
638            "X-GOOGLE-EVENT-ID".to_string(),
639            "abc123".to_string(),
640        ));
641        mock.reply::<rpc::CreateEvent>(canonical.clone());
642
643        connection
644            .apply_outgoing_diff(&outgoing_create_diff(local))
645            .await
646            .unwrap();
647
648        let reloaded = connection.local().events().unwrap();
649        assert_eq!(reloaded.len(), 1);
650        assert_eq!(reloaded[0].event().x_properties, canonical.x_properties);
651        assert_eq!(
652            connection
653                .local()
654                .state()
655                .sync_base(&canonical.event_instance_id()),
656            Some(&canonical)
657        );
658    }
659
660    #[tokio::test]
661    async fn apply_outgoing_diff_rewrites_local_when_provider_reassigns_uid() {
662        // Some providers (e.g. CalDAV servers) re-assign UID server-side. The
663        // local file must still be rewritten with the provider's canonical
664        // event, and the synced state must record the *returned* identity so
665        // the next sync doesn't see a phantom delete + duplicate create.
666        let (_tmp, mock, mut connection) = writable_connection();
667        let local = test_event();
668        let original_id = local.event_instance_id();
669        connection.local().create_event(local.clone()).unwrap();
670
671        let mut canonical = local.clone();
672        canonical.uid = EventUid::new("provider-assigned-uid@example.com");
673        let canonical_id = canonical.event_instance_id();
674        assert_ne!(original_id, canonical_id);
675        mock.reply::<rpc::CreateEvent>(canonical.clone());
676
677        connection
678            .apply_outgoing_diff(&outgoing_create_diff(local))
679            .await
680            .unwrap();
681
682        let reloaded = connection.local().events().unwrap();
683        assert_eq!(reloaded.len(), 1);
684        assert_eq!(reloaded[0].event().uid, canonical.uid);
685
686        let synced = connection.local().state().synced_event_ids();
687        assert!(synced.contains(&canonical_id));
688        assert!(!synced.contains(&original_id));
689    }
690
691    #[tokio::test]
692    async fn apply_outgoing_diff_records_base_for_outgoing_create() {
693        let (_tmp, mock, mut connection) = writable_connection();
694        let event = test_event();
695        let id = event.event_instance_id();
696        connection.local().create_event(event.clone()).unwrap();
697
698        mock.reply::<rpc::CreateEvent>(event.clone());
699        connection
700            .apply_outgoing_diff(&outgoing_create_diff(event.clone()))
701            .await
702            .unwrap();
703
704        assert_eq!(connection.local().state().sync_base(&id), Some(&event));
705    }
706
707    #[tokio::test]
708    async fn apply_outgoing_diff_persists_synced_ids_on_partial_success() {
709        use crate::provider::transport::ProviderTransportError;
710        use std::time::Duration;
711
712        let (_tmp, mock, mut connection) = writable_connection();
713
714        let event_a = test_event();
715        let event_b = test_event();
716        let id_a = event_a.event_instance_id();
717        connection.local().create_event(event_a.clone()).unwrap();
718        connection.local().create_event(event_b.clone()).unwrap();
719
720        // First Create succeeds!
721        // Second errors mid-loop!
722        mock.reply::<rpc::CreateEvent>(event_a.clone());
723        mock.reply_error(ProviderTransportError::Timeout(Duration::from_secs(1)));
724
725        let diff = CalendarDiff::from_changes(
726            vec![
727                EventChange::Create(event_a.clone()),
728                EventChange::Create(event_b),
729            ],
730            vec![],
731        );
732
733        let result = connection.apply_outgoing_diff(&diff).await;
734
735        assert!(
736            result.is_err(),
737            "expected the second create to propagate an error",
738        );
739
740        let reloaded = Calendar::load(connection.local().path()).unwrap();
741
742        assert_eq!(
743            reloaded.state().sync_base(&id_a),
744            Some(&event_a),
745            "event A's base should survive a later push failure",
746        );
747    }
748
749    #[tokio::test]
750    async fn discard_outgoing_diff_deletes_file_for_outgoing_create() {
751        let (_tmp, _mock, connection) = writable_connection();
752        let event = test_event();
753        let cal_event = connection.local().create_event(event.clone()).unwrap();
754        let path = cal_event.path().to_path_buf();
755
756        connection
757            .discard_outgoing_diff(&outgoing_create_diff(event))
758            .unwrap();
759
760        assert!(!path.exists());
761    }
762
763    #[tokio::test]
764    async fn discard_outgoing_diff_reverts_local_update_to_remote_version() {
765        let (_tmp, _mock, connection) = writable_connection();
766        let original = test_event();
767        let mut modified = original.clone();
768        modified.summary = Some("Locally Edited".to_string());
769        connection.local().create_event(modified.clone()).unwrap();
770
771        connection
772            .discard_outgoing_diff(&outgoing_update_diff(original.clone(), modified))
773            .unwrap();
774
775        let reloaded = connection.local().events().unwrap();
776        assert_eq!(reloaded.len(), 1);
777        assert_eq!(reloaded[0].event().summary, original.summary);
778    }
779
780    #[tokio::test]
781    async fn discard_outgoing_diff_recreates_file_for_outgoing_delete() {
782        let (_tmp, _mock, connection) = writable_connection();
783        let event = test_event();
784
785        connection
786            .discard_outgoing_diff(&outgoing_delete_diff(event))
787            .unwrap();
788
789        let expected_path = connection
790            .local()
791            .path()
792            .join("2026-01-01T1200__test-event.ics");
793        assert!(expected_path.is_file());
794    }
795}