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;
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())
}
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);
}
}
}
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
}
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> {
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 {
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 {
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();
assert_eq!(events.len(), 11, "fixture should yield 11 events (cancelled excluded)");
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);
}
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()
);
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 & Three</span><br><span>Item Four</span>")
);
assert_eq!(
oneoff.start,
DateTime::parse_from_rfc3339("2026-05-06T18:00:00Z").unwrap().to_utc()
);
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()
);
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()
);
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()
);
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");
assert_eq!(allday.start, Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap());
assert_eq!(allday.end, Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap());
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();
let items = response.items.unwrap();
assert!(
items.iter().any(|i| i.id.as_deref() == Some("cancelled001")),
"cancelled001 must be present in the raw items"
);
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());
}
}