Skip to main content

gcal_fetcher/
lib.rs

1//! # gcal-fetcher
2//!
3//! Fetch events from the [Google Calendar JSON API](https://developers.google.com/calendar/api/v3/reference/events/list)
4//! using only an API key — no OAuth required.
5//!
6//! Only public Google Calendars are supported. All timestamps are returned as
7//! [`chrono::DateTime<Utc>`]; timezone conversion is left to the caller.
8//!
9//! This crate exposes [`chrono::DateTime<Utc>`] in its public API. The
10//! `chrono` crate is re-exported as [`gcal_fetcher::chrono`](chrono) so you don't
11//! need a direct dependency on it. If you do depend on `chrono` directly,
12//! pin a version compatible with `0.4` to avoid type-mismatch errors.
13//!
14//! Right now only blocking is supported, this was done to keep dependencies small.
15//! A future version might include a feature flag to support async execution.
16//!
17//! ## Usage
18//!
19//! ```no_run
20//! use std::num::NonZeroI32;
21//! use gcal_fetcher::fetch_events;
22//!
23//! let calendar_id = "your-calendar-id@group.calendar.google.com";
24//! let api_key     = "YOUR_GOOGLE_API_KEY";
25//! let fetch_days  = NonZeroI32::new(30).unwrap();
26//!
27//! match fetch_events(calendar_id, api_key, fetch_days) {
28//!     Ok(events) => {
29//!         for event in &events {
30//!             println!("{} — {} to {}", event.summary, event.start, event.end);
31//!             if let Some(loc) = &event.location {
32//!                 println!("  📍 {}", loc);
33//!             }
34//!         }
35//!         println!("{} event(s) found.", events.len());
36//!     }
37//!     Err(e) => eprintln!("Error: {}", e),
38//! }
39//! ```
40
41mod error;
42mod event;
43mod api;
44
45pub use chrono;
46#[doc(no_inline)]
47pub use chrono::{DateTime, Utc};
48pub use error::FetchError;
49pub use event::CalendarEvent;
50use chrono::{NaiveDate, TimeDelta, TimeZone};
51use api::{ApiEvent, EventDateTime, EventsListResponse};
52use std::num::NonZeroI32;
53use crate::event::EventTimeType;
54
55/// Fetch events from Google Calendar JSON API.
56/// Uses `singleEvents=true` to expand recurring events server-side.
57/// Fetches events from "now" up to the given days in the future.
58///
59/// Events are fetched if any part of it overlaps the check window as per [Google Calendar API `events.list` reference][events-list]
60/// 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.
61///
62/// # Arguments
63/// * `calendar_id` — public calendar ID, e.g. `"…@group.calendar.google.com"`
64/// * `api_key` — Google Cloud API key with Calendar API enabled
65/// * `fetch_days` — must be a non-zero integer, negative values to fetch past events is technically supported but not tested.
66///
67/// # Errors
68/// * [`FetchError::Http`] — network or non-2xx response (including invalid range)
69/// * [`FetchError::Io`] — failed to read response body
70/// * [`FetchError::Parse`] — response wasn't valid JSON in the expected schema
71///
72/// [events-list]: https://developers.google.com/calendar/api/v3/reference/events/list
73pub fn fetch_events(calendar_id: &str, api_key: &str, fetch_days: NonZeroI32) -> Result<Vec<CalendarEvent>, FetchError> {
74    fetch_events_starting_from(calendar_id, api_key, fetch_days, Utc::now())
75}
76
77/// Same as [`fetch_events`] but lets you define a "now".
78pub fn fetch_events_starting_from(
79    calendar_id: &str,
80    api_key: &str,
81    fetch_days: NonZeroI32,
82    now: DateTime<Utc>,
83) -> Result<Vec<CalendarEvent>, FetchError> {
84    let day_diff = fetch_days.get() as i64;
85    let time_min;
86    let time_max;
87    if day_diff < 0 {
88        time_min = (now + TimeDelta::days(day_diff)).to_rfc3339();
89        time_max = now.to_rfc3339();
90    } else {
91        time_min = now.to_rfc3339();
92        time_max = (now + TimeDelta::days(day_diff)).to_rfc3339();
93    }
94
95    let mut all_events = Vec::new();
96    let mut page_token: Option<String> = None;
97    let mut response = fetch_events_page(calendar_id, api_key, &time_min, &time_max, &page_token)?;
98
99    loop {
100        if let Some(items) = response.items {
101            for item in items {
102                if let Some(event) = parse_api_event(item) {
103                    all_events.push(event);
104                }
105            }
106        }
107
108        // if over 2500 events, we get a continuation token and loop
109        match response.next_page_token {
110            Some(token) => page_token = Some(token),
111            None => break,
112        }
113        response = fetch_events_page(calendar_id, api_key, &time_min, &time_max, &page_token)?;
114    }
115
116    all_events.sort_by_key(|e| e.start);
117    Ok(all_events)
118}
119
120fn fetch_events_page(calendar_id: &str, api_key: &str, time_min: &str, time_max: &str, page_token: &Option<String>) -> Result<EventsListResponse, FetchError> {
121    let url = build_url(calendar_id, api_key, time_min, time_max, page_token.as_deref());
122    let agent = ureq::Agent::config_builder()
123        .timeout_global(Some(std::time::Duration::from_secs(60)))
124        .build()
125        .new_agent();
126    let response_body = agent.get(&url)
127        .call()
128        .map_err(FetchError::Http)?
129        .body_mut()
130        .read_to_string()?;
131
132    let response: EventsListResponse =
133        serde_json::from_str(&response_body).map_err(FetchError::Parse)?;
134    Ok(response)
135}
136
137fn build_url(
138    calendar_id: &str,
139    api_key: &str,
140    time_min: &str,
141    time_max: &str,
142    page_token: Option<&str>,
143) -> String {
144    let mut url = format!(
145        "https://www.googleapis.com/calendar/v3/calendars/{}/events\
146         ?key={}\
147         &timeMin={}\
148         &timeMax={}\
149         &singleEvents=true\
150         &orderBy=startTime\
151         &maxResults=2500",
152        url_encode(calendar_id),
153        url_encode(api_key),
154        url_encode(time_min),
155        url_encode(time_max),
156    );
157    if let Some(token) = page_token {
158        url.push_str("&pageToken=");
159        url.push_str(token);
160    }
161    url
162}
163
164/// Incomplete hand-rolled url encode
165/// Good enough for calendar ids & date-times.
166/// Google API keys should not need encoding anyway.
167fn url_encode(s: &str) -> String {
168    s.replace('%', "%25")
169        .replace(' ', "%20")
170        .replace('+', "%2B")
171        .replace('#', "%23")
172        .replace('&', "%26")
173        .replace('=', "%3D")
174        .replace('@', "%40")
175        .replace(':', "%3A")
176}
177
178fn parse_api_event(item: ApiEvent) -> Option<CalendarEvent> {
179    // Skip cancelled events
180    if item.status.as_deref() == Some("cancelled") {
181        return None;
182    }
183
184    let id = item.id?;
185    let etag = item.etag?;
186    let summary = item.summary.unwrap_or_else(|| "(No title)".to_string());
187    let start_time = parse_event_datetime(&item.start?)?;
188    let end_time = parse_event_datetime(&item.end?)?;
189    let is_whole_day_event = matches!(start_time, EventTimeType::Date(_));
190    let start = start_time.into_datetime();
191    let end = end_time.into_datetime();
192
193    Some(CalendarEvent {
194        id,
195        etag,
196        summary,
197        start,
198        end,
199        description: item.description,
200        location: item.location,
201        is_whole_day_event,
202    })
203}
204
205fn parse_event_datetime(dt: &EventDateTime) -> Option<EventTimeType> {
206    if let Some(ref date_time_str) = dt.date_time {
207        // RFC3339 format: "2026-05-01T13:00:00+02:00" or "2026-05-01T11:00:00Z"
208        DateTime::parse_from_rfc3339(date_time_str)
209            .ok()
210            .map(|d| d.to_utc())
211            .map(EventTimeType::DateTime)
212    } else if let Some(ref date_str) = dt.date {
213        // All-day event: "2026-05-01"
214        let naive = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()?;
215        Some(EventTimeType::Date(Utc.from_utc_datetime(&naive.and_hms_opt(0, 0, 0)?)))
216    } else {
217        None
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    mod parse_date_times {
226        use super::*;
227        #[test]
228        fn parse_rfc3339_datetime() {
229            let dt = EventDateTime {
230                date_time: Some("2026-05-01T13:00:00+02:00".to_string()),
231                date: None,
232            };
233            let result = parse_event_datetime(&dt).unwrap();
234            assert_eq!(matches!(result, EventTimeType::DateTime(_)), true);
235            assert_eq!(result.into_datetime(), DateTime::parse_from_rfc3339("2026-05-01T11:00:00Z").unwrap().to_utc());
236        }
237
238        #[test]
239        fn parse_all_day_date() {
240            let dt = EventDateTime {
241                date_time: None,
242                date: Some("2026-05-01".to_string()),
243            };
244            let result = parse_event_datetime(&dt).unwrap();
245            assert_eq!(matches!(result, EventTimeType::Date(_)), true);
246            assert_eq!(result.into_datetime(), Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap());
247        }
248    }
249
250    mod parse_json_fixture {
251        use super::*;
252        #[test]
253        fn parses_fixture_into_calendar_events() {
254            let fixture = include_str!("../tests/fixture.json");
255            let response: EventsListResponse =
256                serde_json::from_str(fixture).expect("fixture.json should parse");
257
258            let items = response.items.expect("fixture has items");
259            let events: Vec<CalendarEvent> = items.into_iter().filter_map(parse_api_event).collect();
260
261            // 12 events minus one cancelled
262            assert_eq!(events.len(), 11, "fixture should yield 11 events (cancelled excluded)");
263
264            // Every event must have a non-empty title and end > start.
265            for event in &events {
266                assert!(!event.summary.is_empty(), "summary must not be empty: {:?}", event);
267                assert!(event.end > event.start, "end must be after start: {:?}", event);
268            }
269
270            // First event: recurring instance "Sample Event A".
271            let first = &events[0];
272            assert_eq!(first.id, "series001_20260508T120000Z");
273            assert_eq!(first.etag, "\"1111111111111111\"");
274            assert_eq!(first.summary, "Sample Event A");
275            assert_eq!(first.description, None);
276            assert_eq!(
277                first.start,
278                DateTime::parse_from_rfc3339("2026-05-06T12:30:00Z").unwrap().to_utc()
279            );
280            assert_eq!(
281                first.end,
282                DateTime::parse_from_rfc3339("2026-05-06T18:00:00Z").unwrap().to_utc()
283            );
284
285            // One-off event preserves its HTML description verbatim (escaped entities are decoded by serde_json).
286            let oneoff = events.iter().find(|e| e.id == "oneoff001").expect("one-off event present");
287            assert_eq!(oneoff.summary, "Sample One-Off Event");
288            assert_eq!(oneoff.etag, "\"2222222222222222\"");
289            assert_eq!(
290                oneoff.description.as_deref(),
291                Some("<span>Item One</span><br><span>Item Two &amp; Three</span><br><span>Item Four</span>")
292            );
293            assert_eq!(
294                oneoff.start,
295                DateTime::parse_from_rfc3339("2026-05-06T18:00:00Z").unwrap().to_utc()
296            );
297
298            // Moved recurring instance: id encodes 2026-05-06 but actual start is 2026-05-08.
299            // We trust `start`/`end`, not the id.
300            let moved = events
301                .iter()
302                .find(|e| e.id == "series001_20260506T120000Z")
303                .expect("moved instance present");
304            assert_eq!(moved.summary, "Sample Event B");
305            assert_eq!(
306                moved.start,
307                DateTime::parse_from_rfc3339("2026-05-08T09:00:00Z").unwrap().to_utc()
308            );
309            assert_eq!(
310                moved.end,
311                DateTime::parse_from_rfc3339("2026-05-08T15:00:00Z").unwrap().to_utc()
312            );
313
314            // Event from the second recurring series carries its own description.
315            let recurring = events
316                .iter()
317                .find(|e| e.id == "series002_20260510T080000Z")
318                .expect("series002 instance present");
319            assert_eq!(recurring.summary, "Sample Recurring Event");
320            assert_eq!(recurring.description.as_deref(), Some("Sample description"));
321            assert_eq!(recurring.etag, "\"4444444444444444\"");
322            assert_eq!(
323                recurring.start,
324                DateTime::parse_from_rfc3339("2026-05-10T09:30:00Z").unwrap().to_utc()
325            );
326            assert_eq!(
327                recurring.end,
328                DateTime::parse_from_rfc3339("2026-05-10T15:30:00Z").unwrap().to_utc()
329            );
330
331            // Last event in fixture order: notitle001 (no summary field → "(No title)" fallback).
332            let last = events.last().unwrap();
333            assert_eq!(last.id, "notitle001");
334            assert_eq!(last.summary, "(No title)");
335            assert_eq!(
336                last.start,
337                DateTime::parse_from_rfc3339("2026-05-16T09:00:00Z").unwrap().to_utc()
338            );
339            assert_eq!(
340                last.end,
341                DateTime::parse_from_rfc3339("2026-05-16T10:00:00Z").unwrap().to_utc()
342            );
343
344            // Only two events in the fixture have descriptions.
345            let with_description = events.iter().filter(|e| e.description.is_some()).count();
346            assert_eq!(with_description, 2);
347        }
348
349        #[test]
350        fn all_day_event_is_detected_in_fixture() {
351            let fixture = include_str!("../tests/fixture.json");
352            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
353            let events: Vec<CalendarEvent> = response
354                .items.unwrap().into_iter().filter_map(parse_api_event).collect();
355
356            let allday = events.iter().find(|e| e.id == "allday001").expect("allday001 present");
357            assert!(allday.is_whole_day_event, "allday001 must be flagged as a whole-day event");
358            assert_eq!(allday.summary, "Sample All-Day Event");
359            // date "2026-05-15" → 2026-05-15T00:00:00Z
360            assert_eq!(allday.start, Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap());
361            // end date "2026-05-16" → 2026-05-16T00:00:00Z
362            assert_eq!(allday.end, Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap());
363
364            // All other confirmed events must NOT be all-day.
365            let non_allday_whole_day = events.iter()
366                .filter(|e| e.id != "allday001" && e.is_whole_day_event)
367                .count();
368            assert_eq!(non_allday_whole_day, 0);
369        }
370
371        #[test]
372        fn cancelled_event_in_fixture_is_filtered_out() {
373            let fixture = include_str!("../tests/fixture.json");
374            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
375            // The raw items array contains cancelled001.
376            let items = response.items.unwrap();
377            assert!(
378                items.iter().any(|i| i.id.as_deref() == Some("cancelled001")),
379                "cancelled001 must be present in the raw items"
380            );
381            // After parse_api_event it must be gone.
382            let events: Vec<CalendarEvent> = items.into_iter().filter_map(parse_api_event).collect();
383            assert!(
384                events.iter().all(|e| e.id != "cancelled001"),
385                "cancelled001 must not appear in parsed events"
386            );
387        }
388
389        #[test]
390        fn location_is_preserved_in_fixture() {
391            let fixture = include_str!("../tests/fixture.json");
392            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
393            let events: Vec<CalendarEvent> = response
394                .items.unwrap().into_iter().filter_map(parse_api_event).collect();
395
396            let loc_event = events.iter().find(|e| e.id == "location001").expect("location001 present");
397            assert_eq!(loc_event.location.as_deref(), Some("Sample Venue, Amsterdam, Netherlands"));
398        }
399
400        #[test]
401        fn missing_summary_falls_back_to_no_title_in_fixture() {
402            let fixture = include_str!("../tests/fixture.json");
403            let response: EventsListResponse = serde_json::from_str(fixture).unwrap();
404            let events: Vec<CalendarEvent> = response
405                .items.unwrap().into_iter().filter_map(parse_api_event).collect();
406
407            let notitle = events.iter().find(|e| e.id == "notitle001").expect("notitle001 present");
408            assert_eq!(notitle.summary, "(No title)");
409        }
410    }
411
412    #[test]
413    fn cancelled_events_are_skipped() {
414        let item = ApiEvent {
415            id: Some("test123".to_string()),
416            etag: Some("\"etag123\"".to_string()),
417            summary: Some("Cancelled Event".to_string()),
418            start: Some(EventDateTime { date_time: Some("2026-05-01T10:00:00Z".to_string()), date: None }),
419            end: Some(EventDateTime { date_time: Some("2026-05-01T11:00:00Z".to_string()), date: None }),
420            description: None,
421            location: None,
422            status: Some("cancelled".to_string()),
423        };
424        assert!(parse_api_event(item).is_none());
425    }
426}
427