1mod 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
55pub 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
77pub 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 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
164fn 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 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 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 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 assert_eq!(events.len(), 11, "fixture should yield 11 events (cancelled excluded)");
263
264 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 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 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 & 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 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 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 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 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 assert_eq!(allday.start, Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap());
361 assert_eq!(allday.end, Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap());
363
364 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 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 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