gcal-fetcher 0.1.0

Fetch events from the Google Calendar JSON API looking a given number of days ahead.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! # gcal-fetcher
//!
//! Fetch events from the [Google Calendar JSON API](https://developers.google.com/calendar/api/v3/reference/events/list)
//! using only an API key — no OAuth required.
//!
//! Only public Google Calendars are supported. All timestamps are returned as
//! [`chrono::DateTime<Utc>`]; timezone conversion is left to the caller.
//!
//! This crate exposes [`chrono::DateTime<Utc>`] in its public API. The
//! `chrono` crate is re-exported as [`gcal_fetcher::chrono`](chrono) so you don't
//! need a direct dependency on it. If you do depend on `chrono` directly,
//! pin a version compatible with `0.4` to avoid type-mismatch errors.
//!
//! Right now only blocking is supported, this was done to keep dependencies small.
//! A future version might include a feature flag to support async execution.
//!
//! ## Usage
//!
//! ```no_run
//! use std::num::NonZeroI32;
//! use gcal_fetcher::fetch_events;
//!
//! let calendar_id = "your-calendar-id@group.calendar.google.com";
//! let api_key     = "YOUR_GOOGLE_API_KEY";
//! let fetch_days  = NonZeroI32::new(30).unwrap();
//!
//! match fetch_events(calendar_id, api_key, fetch_days) {
//!     Ok(events) => {
//!         for event in &events {
//!             println!("{} — {} to {}", event.summary, event.start, event.end);
//!             if let Some(loc) = &event.location {
//!                 println!("  📍 {}", loc);
//!             }
//!         }
//!         println!("{} event(s) found.", events.len());
//!     }
//!     Err(e) => eprintln!("Error: {}", e),
//! }
//! ```

mod error;
mod event;
mod api;

pub use chrono;
#[doc(no_inline)]
pub use chrono::{DateTime, Utc};
pub use error::FetchError;
pub use event::CalendarEvent;
use chrono::{NaiveDate, TimeDelta, TimeZone};
use api::{ApiEvent, EventDateTime, EventsListResponse};
use std::num::NonZeroI32;
use crate::event::EventTimeType;

/// Fetch events from Google Calendar JSON API.
/// Uses `singleEvents=true` to expand recurring events server-side.
/// Fetches events from "now" up to the given days in the future.
///
/// Events are fetched if any part of it overlaps the check window as per [Google Calendar API `events.list` reference][events-list]
/// If a request would fetch more than 2500 events, you should probably rethink your request. But they will still be fetched using multiple requests and pagination. This has not been tested though.
///
/// # Arguments
/// * `calendar_id` — public calendar ID, e.g. `"…@group.calendar.google.com"`
/// * `api_key` — Google Cloud API key with Calendar API enabled
/// * `fetch_days` — must be a non-zero integer, negative values to fetch past events is technically supported but not tested.
///
/// # Errors
/// * [`FetchError::Http`] — network or non-2xx response (including invalid range)
/// * [`FetchError::Io`] — failed to read response body
/// * [`FetchError::Parse`] — response wasn't valid JSON in the expected schema
///
/// [events-list]: https://developers.google.com/calendar/api/v3/reference/events/list
pub fn fetch_events(calendar_id: &str, api_key: &str, fetch_days: NonZeroI32) -> Result<Vec<CalendarEvent>, FetchError> {
    fetch_events_starting_from(calendar_id, api_key, fetch_days, Utc::now())
}

/// Same as [`fetch_events`] but lets you define a "now".
pub fn fetch_events_starting_from(
    calendar_id: &str,
    api_key: &str,
    fetch_days: NonZeroI32,
    now: DateTime<Utc>,
) -> Result<Vec<CalendarEvent>, FetchError> {
    let day_diff = fetch_days.get() as i64;
    let time_min;
    let time_max;
    if day_diff < 0 {
        time_min = (now + TimeDelta::days(day_diff)).to_rfc3339();
        time_max = now.to_rfc3339();
    } else {
        time_min = now.to_rfc3339();
        time_max = (now + TimeDelta::days(day_diff)).to_rfc3339();
    }

    let mut all_events = Vec::new();
    let mut page_token: Option<String> = None;
    let mut response = fetch_events_page(calendar_id, api_key, &time_min, &time_max, &page_token)?;

    loop {
        if let Some(items) = response.items {
            for item in items {
                if let Some(event) = parse_api_event(item) {
                    all_events.push(event);
                }
            }
        }

        // if over 2500 events, we get a continuation token and loop
        match response.next_page_token {
            Some(token) => page_token = Some(token),
            None => break,
        }
        response = fetch_events_page(calendar_id, api_key, &time_min, &time_max, &page_token)?;
    }

    all_events.sort_by_key(|e| e.start);
    Ok(all_events)
}

fn fetch_events_page(calendar_id: &str, api_key: &str, time_min: &str, time_max: &str, page_token: &Option<String>) -> Result<EventsListResponse, FetchError> {
    let url = build_url(calendar_id, api_key, time_min, time_max, page_token.as_deref());
    let agent = ureq::Agent::config_builder()
        .timeout_global(Some(std::time::Duration::from_secs(60)))
        .build()
        .new_agent();
    let response_body = agent.get(&url)
        .call()
        .map_err(FetchError::Http)?
        .body_mut()
        .read_to_string()?;

    let response: EventsListResponse =
        serde_json::from_str(&response_body).map_err(FetchError::Parse)?;
    Ok(response)
}

fn build_url(
    calendar_id: &str,
    api_key: &str,
    time_min: &str,
    time_max: &str,
    page_token: Option<&str>,
) -> String {
    let mut url = format!(
        "https://www.googleapis.com/calendar/v3/calendars/{}/events\
         ?key={}\
         &timeMin={}\
         &timeMax={}\
         &singleEvents=true\
         &orderBy=startTime\
         &maxResults=2500",
        url_encode(calendar_id),
        url_encode(api_key),
        url_encode(time_min),
        url_encode(time_max),
    );
    if let Some(token) = page_token {
        url.push_str("&pageToken=");
        url.push_str(token);
    }
    url
}

/// Incomplete hand-rolled url encode
/// Good enough for calendar ids & date-times.
/// Google API keys should not need encoding anyway.
fn url_encode(s: &str) -> String {
    s.replace('%', "%25")
        .replace(' ', "%20")
        .replace('+', "%2B")
        .replace('#', "%23")
        .replace('&', "%26")
        .replace('=', "%3D")
        .replace('@', "%40")
        .replace(':', "%3A")
}

fn parse_api_event(item: ApiEvent) -> Option<CalendarEvent> {
    // Skip cancelled events
    if item.status.as_deref() == Some("cancelled") {
        return None;
    }

    let id = item.id?;
    let etag = item.etag?;
    let summary = item.summary.unwrap_or_else(|| "(No title)".to_string());
    let start_time = parse_event_datetime(&item.start?)?;
    let end_time = parse_event_datetime(&item.end?)?;
    let is_whole_day_event = matches!(start_time, EventTimeType::Date(_));
    let start = start_time.into_datetime();
    let end = end_time.into_datetime();

    Some(CalendarEvent {
        id,
        etag,
        summary,
        start,
        end,
        description: item.description,
        location: item.location,
        is_whole_day_event,
    })
}

fn parse_event_datetime(dt: &EventDateTime) -> Option<EventTimeType> {
    if let Some(ref date_time_str) = dt.date_time {
        // RFC3339 format: "2026-05-01T13:00:00+02:00" or "2026-05-01T11:00:00Z"
        DateTime::parse_from_rfc3339(date_time_str)
            .ok()
            .map(|d| d.to_utc())
            .map(EventTimeType::DateTime)
    } else if let Some(ref date_str) = dt.date {
        // All-day event: "2026-05-01"
        let naive = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()?;
        Some(EventTimeType::Date(Utc.from_utc_datetime(&naive.and_hms_opt(0, 0, 0)?)))
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    mod parse_date_times {
        use super::*;
        #[test]
        fn parse_rfc3339_datetime() {
            let dt = EventDateTime {
                date_time: Some("2026-05-01T13:00:00+02:00".to_string()),
                date: None,
            };
            let result = parse_event_datetime(&dt).unwrap();
            assert_eq!(matches!(result, EventTimeType::DateTime(_)), true);
            assert_eq!(result.into_datetime(), DateTime::parse_from_rfc3339("2026-05-01T11:00:00Z").unwrap().to_utc());
        }

        #[test]
        fn parse_all_day_date() {
            let dt = EventDateTime {
                date_time: None,
                date: Some("2026-05-01".to_string()),
            };
            let result = parse_event_datetime(&dt).unwrap();
            assert_eq!(matches!(result, EventTimeType::Date(_)), true);
            assert_eq!(result.into_datetime(), Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap());
        }
    }

    mod parse_json_fixture {
        use super::*;
        #[test]
        fn parses_fixture_into_calendar_events() {
            let fixture = include_str!("../tests/fixture.json");
            let response: EventsListResponse =
                serde_json::from_str(fixture).expect("fixture.json should parse");

            let items = response.items.expect("fixture has items");
            let events: Vec<CalendarEvent> = items.into_iter().filter_map(parse_api_event).collect();

            // 12 events minus one cancelled
            assert_eq!(events.len(), 11, "fixture should yield 11 events (cancelled excluded)");

            // Every event must have a non-empty title and end > start.
            for event in &events {
                assert!(!event.summary.is_empty(), "summary must not be empty: {:?}", event);
                assert!(event.end > event.start, "end must be after start: {:?}", event);
            }

            // First event: recurring instance "Sample Event A".
            let first = &events[0];
            assert_eq!(first.id, "series001_20260508T120000Z");
            assert_eq!(first.etag, "\"1111111111111111\"");
            assert_eq!(first.summary, "Sample Event A");
            assert_eq!(first.description, None);
            assert_eq!(
                first.start,
                DateTime::parse_from_rfc3339("2026-05-06T12:30:00Z").unwrap().to_utc()
            );
            assert_eq!(
                first.end,
                DateTime::parse_from_rfc3339("2026-05-06T18:00:00Z").unwrap().to_utc()
            );

            // One-off event preserves its HTML description verbatim (escaped entities are decoded by serde_json).
            let oneoff = events.iter().find(|e| e.id == "oneoff001").expect("one-off event present");
            assert_eq!(oneoff.summary, "Sample One-Off Event");
            assert_eq!(oneoff.etag, "\"2222222222222222\"");
            assert_eq!(
                oneoff.description.as_deref(),
                Some("<span>Item One</span><br><span>Item Two &amp; Three</span><br><span>Item Four</span>")
            );
            assert_eq!(
                oneoff.start,
                DateTime::parse_from_rfc3339("2026-05-06T18:00:00Z").unwrap().to_utc()
            );

            // Moved recurring instance: id encodes 2026-05-06 but actual start is 2026-05-08.
            // We trust `start`/`end`, not the id.
            let moved = events
                .iter()
                .find(|e| e.id == "series001_20260506T120000Z")
                .expect("moved instance present");
            assert_eq!(moved.summary, "Sample Event B");
            assert_eq!(
                moved.start,
                DateTime::parse_from_rfc3339("2026-05-08T09:00:00Z").unwrap().to_utc()
            );
            assert_eq!(
                moved.end,
                DateTime::parse_from_rfc3339("2026-05-08T15:00:00Z").unwrap().to_utc()
            );

            // Event from the second recurring series carries its own description.
            let recurring = events
                .iter()
                .find(|e| e.id == "series002_20260510T080000Z")
                .expect("series002 instance present");
            assert_eq!(recurring.summary, "Sample Recurring Event");
            assert_eq!(recurring.description.as_deref(), Some("Sample description"));
            assert_eq!(recurring.etag, "\"4444444444444444\"");
            assert_eq!(
                recurring.start,
                DateTime::parse_from_rfc3339("2026-05-10T09:30:00Z").unwrap().to_utc()
            );
            assert_eq!(
                recurring.end,
                DateTime::parse_from_rfc3339("2026-05-10T15:30:00Z").unwrap().to_utc()
            );

            // Last event in fixture order: notitle001 (no summary field → "(No title)" fallback).
            let last = events.last().unwrap();
            assert_eq!(last.id, "notitle001");
            assert_eq!(last.summary, "(No title)");
            assert_eq!(
                last.start,
                DateTime::parse_from_rfc3339("2026-05-16T09:00:00Z").unwrap().to_utc()
            );
            assert_eq!(
                last.end,
                DateTime::parse_from_rfc3339("2026-05-16T10:00:00Z").unwrap().to_utc()
            );

            // Only two events in the fixture have descriptions.
            let with_description = events.iter().filter(|e| e.description.is_some()).count();
            assert_eq!(with_description, 2);
        }

        #[test]
        fn all_day_event_is_detected_in_fixture() {
            let fixture = include_str!("../tests/fixture.json");
            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
            let events: Vec<CalendarEvent> = response
                .items.unwrap().into_iter().filter_map(parse_api_event).collect();

            let allday = events.iter().find(|e| e.id == "allday001").expect("allday001 present");
            assert!(allday.is_whole_day_event, "allday001 must be flagged as a whole-day event");
            assert_eq!(allday.summary, "Sample All-Day Event");
            // date "2026-05-15" → 2026-05-15T00:00:00Z
            assert_eq!(allday.start, Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap());
            // end date "2026-05-16" → 2026-05-16T00:00:00Z
            assert_eq!(allday.end, Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap());

            // All other confirmed events must NOT be all-day.
            let non_allday_whole_day = events.iter()
                .filter(|e| e.id != "allday001" && e.is_whole_day_event)
                .count();
            assert_eq!(non_allday_whole_day, 0);
        }

        #[test]
        fn cancelled_event_in_fixture_is_filtered_out() {
            let fixture = include_str!("../tests/fixture.json");
            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
            // The raw items array contains cancelled001.
            let items = response.items.unwrap();
            assert!(
                items.iter().any(|i| i.id.as_deref() == Some("cancelled001")),
                "cancelled001 must be present in the raw items"
            );
            // After parse_api_event it must be gone.
            let events: Vec<CalendarEvent> = items.into_iter().filter_map(parse_api_event).collect();
            assert!(
                events.iter().all(|e| e.id != "cancelled001"),
                "cancelled001 must not appear in parsed events"
            );
        }

        #[test]
        fn location_is_preserved_in_fixture() {
            let fixture = include_str!("../tests/fixture.json");
            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
            let events: Vec<CalendarEvent> = response
                .items.unwrap().into_iter().filter_map(parse_api_event).collect();

            let loc_event = events.iter().find(|e| e.id == "location001").expect("location001 present");
            assert_eq!(loc_event.location.as_deref(), Some("Sample Venue, Amsterdam, Netherlands"));
        }

        #[test]
        fn missing_summary_falls_back_to_no_title_in_fixture() {
            let fixture = include_str!("../tests/fixture.json");
            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
            let events: Vec<CalendarEvent> = response
                .items.unwrap().into_iter().filter_map(parse_api_event).collect();

            let notitle = events.iter().find(|e| e.id == "notitle001").expect("notitle001 present");
            assert_eq!(notitle.summary, "(No title)");
        }
    }

    #[test]
    fn cancelled_events_are_skipped() {
        let item = ApiEvent {
            id: Some("test123".to_string()),
            etag: Some("\"etag123\"".to_string()),
            summary: Some("Cancelled Event".to_string()),
            start: Some(EventDateTime { date_time: Some("2026-05-01T10:00:00Z".to_string()), date: None }),
            end: Some(EventDateTime { date_time: Some("2026-05-01T11:00:00Z".to_string()), date: None }),
            description: None,
            location: None,
            status: Some("cancelled".to_string()),
        };
        assert!(parse_api_event(item).is_none());
    }
}