CalCalist/src/google/convert.rs
randogoth bc64f19211 Push local changes up to Google, completing the Google leg
Mirrors are created with events.import, updated and deleted with
sendUpdates=none, so a write to an aggregate cannot mail anyone about a meeting
that was already invited from its source.

The import question the plan flagged is settled from Google's own API discovery
document rather than by guesswork: events.import accepts no sendUpdates parameter
at all, while insert, update and delete all do, and it is documented as adding "a
private copy of an existing event". A method with notification behaviour would
need that control. Confirmed live that attendees survive an import with their
response statuses intact.

Testing against the real API caught a defect that unit tests could not: the first
push dropped VALARM entirely and Google substituted the calendar's default
reminders. Alarms are never stripped by decision, so they now map to and from
Google's reminder overrides in both directions, with relative TRIGGER durations
converted to whole minutes. A trigger Google cannot express — absolute, or after
the start — is dropped rather than guessed at.

Known gaps recorded in TODO.md rather than papered over: a series' exceptions are
not pushed, since Google models those as separate events against an existing
series; and a failed Google pull still aborts the cycle, which is safe but stops
the CalDAV side too.

121 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:27:12 +03:00

863 lines
32 KiB
Rust

//! Turning Google's JSON events into iCalendar, and back.
//!
//! The shapes here were established by reading what the API actually returns,
//! not from the reference alone. Three things drive the design:
//!
//! * A recurring event's exceptions carry the **same `iCalUID`** as their master,
//! so a master and all its exceptions belong in one file — which is exactly the
//! vdir convention of one file per UID.
//! * `start.dateTime` is an absolute instant while `start.timeZone` names the zone
//! the recurrence expands in, and **the two need not agree**: a real event here
//! reads `2023-10-31T13:00:00+02:00` with `timeZone: Asia/Karachi`, which is
//! +05:00. Emitting the instant under that TZID without converting it would move
//! the event by three hours.
//! * An exception that was deleted comes back as an override with
//! `status: cancelled`, which is an `EXDATE` on the master rather than an event.
use std::collections::BTreeMap;
use jiff::{Timestamp, tz::TimeZone};
use serde::Deserialize;
use thiserror::Error;
use crate::ical::Calendar;
use crate::mirror::escape_text;
#[derive(Debug, Error)]
pub enum ConvertError {
#[error("event {id} has no iCalUID, so it cannot be stored")]
NoUid { id: String },
#[error("event {id} has neither a start date nor a start time")]
NoStart { id: String },
#[error("event {id} has an unparseable {field} `{value}`: {source}")]
BadTime {
id: String,
field: &'static str,
value: String,
#[source]
source: jiff::Error,
},
}
/// A Google calendar event, limited to the fields calcalist maps.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Event {
pub id: String,
#[serde(rename = "iCalUID")]
pub ical_uid: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub start: Option<EventTime>,
#[serde(default)]
pub end: Option<EventTime>,
/// Raw iCalendar recurrence lines — RRULE, EXDATE, RDATE — passed through.
#[serde(default)]
pub recurrence: Option<Vec<String>>,
#[serde(default)]
pub recurring_event_id: Option<String>,
#[serde(default)]
pub original_start_time: Option<EventTime>,
#[serde(default)]
pub transparency: Option<String>,
#[serde(default)]
pub sequence: Option<i64>,
#[serde(default)]
pub updated: Option<String>,
#[serde(default)]
pub reminders: Option<Reminders>,
}
/// Google models alarms as minutes before the start, rather than as components.
///
/// `useDefault` is deliberately not read: it names the calendar's own default
/// reminders, which are a property of that calendar rather than of the event, so
/// there is nothing to carry. An empty `overrides` already yields no alarm.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Reminders {
#[serde(default)]
pub overrides: Vec<Reminder>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Reminder {
pub method: String,
pub minutes: i64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventTime {
/// Set for all-day events, as `YYYY-MM-DD`.
#[serde(default)]
pub date: Option<String>,
/// An absolute instant, RFC 3339.
#[serde(default)]
pub date_time: Option<String>,
/// The zone the recurrence expands in — not necessarily the offset above.
#[serde(default)]
pub time_zone: Option<String>,
}
impl Event {
pub fn is_cancelled(&self) -> bool {
self.status.as_deref() == Some("cancelled")
}
pub fn is_override(&self) -> bool {
self.recurring_event_id.is_some()
}
fn is_recurring(&self) -> bool {
self.recurrence
.as_ref()
.is_some_and(|lines| !lines.is_empty())
}
}
/// A master event together with its exceptions, all sharing one UID.
#[derive(Debug, Default)]
pub struct Group {
pub master: Option<Event>,
pub overrides: Vec<Event>,
}
/// Groups a page of events by the UID they will be stored under.
pub fn group_by_uid(events: Vec<Event>) -> BTreeMap<String, Group> {
let mut groups: BTreeMap<String, Group> = BTreeMap::new();
for event in events {
let Some(uid) = event.ical_uid.clone() else {
continue;
};
let group = groups.entry(uid).or_default();
if event.is_override() {
group.overrides.push(event);
} else {
group.master = Some(event);
}
}
groups
}
/// Renders a group as one iCalendar object.
pub fn to_ical(uid: &str, group: &Group) -> Result<String, ConvertError> {
let Some(master) = &group.master else {
// Exceptions arrived without their master, which happens on an
// incremental sync. The caller merges these into the stored file.
return Err(ConvertError::NoUid {
id: uid.to_string(),
});
};
let mut out = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calcalist//EN\r\n");
out.push_str(&render_event(uid, master, None)?);
// A deleted occurrence is an absence, not an event: it belongs on the master
// as an EXDATE.
for cancelled in group.overrides.iter().filter(|event| event.is_cancelled()) {
if let Some(start) = &cancelled.original_start_time {
let line = time_property("EXDATE", start, &cancelled.id, master.is_recurring())?;
// Insert before the master's END:VEVENT.
let end = out.rfind("END:VEVENT\r\n").unwrap_or(out.len());
out.insert_str(end, &format!("{line}\r\n"));
}
}
for moved in group.overrides.iter().filter(|event| !event.is_cancelled()) {
out.push_str(&render_event(
uid,
moved,
moved.original_start_time.as_ref(),
)?);
}
out.push_str("END:VCALENDAR\r\n");
Ok(out)
}
fn render_event(
uid: &str,
event: &Event,
recurrence_id: Option<&EventTime>,
) -> Result<String, ConvertError> {
let recurring = event.is_recurring() || recurrence_id.is_some();
let mut out = String::from("BEGIN:VEVENT\r\n");
out.push_str(&format!("UID:{uid}\r\n"));
out.push_str(&format!("DTSTAMP:{}\r\n", stamp(event)?));
if let Some(original) = recurrence_id {
out.push_str(&time_property(
"RECURRENCE-ID",
original,
&event.id,
recurring,
)?);
out.push_str("\r\n");
}
let start = event.start.as_ref().ok_or_else(|| ConvertError::NoStart {
id: event.id.clone(),
})?;
out.push_str(&time_property("DTSTART", start, &event.id, recurring)?);
out.push_str("\r\n");
if let Some(end) = &event.end {
out.push_str(&time_property("DTEND", end, &event.id, recurring)?);
out.push_str("\r\n");
}
for line in event.recurrence.iter().flatten() {
out.push_str(line);
out.push_str("\r\n");
}
for (name, value) in [
("SUMMARY", event.summary.as_deref()),
("DESCRIPTION", event.description.as_deref()),
("LOCATION", event.location.as_deref()),
] {
if let Some(value) = value {
out.push_str(&format!("{name}:{}\r\n", escape_text(value)));
}
}
if let Some(transparency) = &event.transparency {
let value = if transparency == "transparent" {
"TRANSPARENT"
} else {
"OPAQUE"
};
out.push_str(&format!("TRANSP:{value}\r\n"));
}
if event.status.as_deref() == Some("tentative") {
out.push_str("STATUS:TENTATIVE\r\n");
}
if let Some(sequence) = event.sequence {
out.push_str(&format!("SEQUENCE:{sequence}\r\n"));
}
// Alarms are always carried across. `useDefault` is a property of the
// calendar rather than the event, so it has no VALARM to become — the
// aggregate's own defaults apply instead.
for reminder in event
.reminders
.iter()
.flat_map(|reminders| reminders.overrides.iter())
{
let action = if reminder.method == "email" {
"EMAIL"
} else {
"DISPLAY"
};
out.push_str(&format!(
"BEGIN:VALARM\r\nACTION:{action}\r\nTRIGGER:-PT{}M\r\nDESCRIPTION:Reminder\r\nEND:VALARM\r\n",
reminder.minutes
));
}
out.push_str("END:VEVENT\r\n");
Ok(out)
}
/// Renders one date-or-time property.
///
/// Timed values are written in UTC unless the event recurs. A recurrence has to
/// expand in its own zone or it drifts by an hour across a daylight-saving
/// change, so those carry a TZID — and the instant is converted into that zone
/// first, since Google's offset and its `timeZone` may disagree.
fn time_property(
name: &str,
time: &EventTime,
id: &str,
recurring: bool,
) -> Result<String, ConvertError> {
if let Some(date) = &time.date {
return Ok(format!("{name};VALUE=DATE:{}", date.replace('-', "")));
}
let value = time
.date_time
.as_deref()
.ok_or_else(|| ConvertError::NoStart { id: id.to_string() })?;
let instant: Timestamp = value.parse().map_err(|source| ConvertError::BadTime {
id: id.to_string(),
field: "dateTime",
value: value.to_string(),
source,
})?;
let zone = time
.time_zone
.as_deref()
.filter(|_| recurring)
.and_then(|name| TimeZone::get(name).ok().map(|zone| (name, zone)));
match zone {
Some((zone_name, zone)) => {
let local = instant.to_zoned(zone);
Ok(format!(
"{name};TZID={zone_name}:{}",
local.strftime("%Y%m%dT%H%M%S")
))
}
None => Ok(format!(
"{name}:{}",
instant.to_zoned(TimeZone::UTC).strftime("%Y%m%dT%H%M%SZ")
)),
}
}
/// DTSTAMP, taken from the event's last update where Google supplies one.
fn stamp(event: &Event) -> Result<String, ConvertError> {
let Some(updated) = &event.updated else {
return Ok(Timestamp::now().strftime("%Y%m%dT%H%M%SZ").to_string());
};
let instant: Timestamp = updated.parse().map_err(|source| ConvertError::BadTime {
id: event.id.clone(),
field: "updated",
value: updated.clone(),
source,
})?;
Ok(instant.strftime("%Y%m%dT%H%M%SZ").to_string())
}
/// Renders a stored calendar as the JSON body Google expects.
///
/// Only the master is described. A series' exceptions cannot be expressed in the
/// same request — Google models them as separate events against an already
/// existing series — so the caller applies those afterwards.
pub fn to_google(calendar: &Calendar) -> Result<serde_json::Value, ConvertError> {
let uid = calendar.uid().ok_or_else(|| ConvertError::NoUid {
id: "<local>".to_string(),
})?;
let mut event = serde_json::Map::new();
event.insert("iCalUID".into(), uid.into());
event.insert("status".into(), status_of(calendar).into());
for (property, field) in [
("SUMMARY", "summary"),
("DESCRIPTION", "description"),
("LOCATION", "location"),
] {
if let Some(value) = master_property(calendar, property) {
event.insert(field.into(), unescape_text(&value).into());
}
}
event.insert("start".into(), google_time(calendar, "DTSTART")?);
if let Some(end) = calendar
.properties("VEVENT", "DTEND")
.next()
.map(|_| google_time(calendar, "DTEND"))
.transpose()?
{
event.insert("end".into(), end);
}
// RRULE, EXDATE and RDATE are iCalendar lines on both sides, so they travel
// verbatim. Their unfolded form is what Google expects.
let recurrence: Vec<serde_json::Value> = ["RRULE", "EXDATE", "RDATE"]
.iter()
.flat_map(|name| calendar.property_lines("VEVENT", name))
.map(|line| serde_json::Value::from(line.to_string()))
.collect();
if !recurrence.is_empty() {
event.insert("recurrence".into(), recurrence.into());
}
if let Some(transp) = master_property(calendar, "TRANSP") {
let value = if transp.eq_ignore_ascii_case("TRANSPARENT") {
"transparent"
} else {
"opaque"
};
event.insert("transparency".into(), value.into());
}
let attendees: Vec<serde_json::Value> = calendar
.properties("VEVENT", "ATTENDEE")
.map(|attendee| {
let mut person = serde_json::Map::new();
person.insert("email".into(), strip_scheme(attendee.value).into());
if let Some(name) = attendee.param("CN") {
person.insert("displayName".into(), name.into());
}
if let Some(status) = attendee.param("PARTSTAT") {
person.insert("responseStatus".into(), response_status(status).into());
}
serde_json::Value::Object(person)
})
.collect();
if !attendees.is_empty() {
event.insert("attendees".into(), attendees.into());
}
// Without this Google silently substitutes the calendar's default reminders,
// which loses the alarms the event actually carried.
let overrides: Vec<serde_json::Value> = calendar
.properties("VALARM", "TRIGGER")
.filter_map(|trigger| minutes_before(trigger.value))
.zip(alarm_methods(calendar))
.map(|(minutes, method)| serde_json::json!({ "method": method, "minutes": minutes }))
.collect();
if !overrides.is_empty() {
event.insert(
"reminders".into(),
serde_json::json!({ "useDefault": false, "overrides": overrides }),
);
}
Ok(serde_json::Value::Object(event))
}
/// The Google reminder method for each VALARM, in the order they appear.
fn alarm_methods(calendar: &Calendar) -> Vec<&'static str> {
calendar
.properties("VALARM", "ACTION")
.map(|action| {
if action.value.eq_ignore_ascii_case("EMAIL") {
"email"
} else {
"popup"
}
})
.collect()
}
/// Converts a relative TRIGGER into whole minutes before the start.
///
/// Google expresses alarms only that way, so an absolute trigger, or one set
/// *after* the start, has no representation and is dropped rather than guessed at.
fn minutes_before(trigger: &str) -> Option<i64> {
let rest = trigger.strip_prefix('-')?.strip_prefix('P')?;
let (days, rest) = split_unit(rest, 'D');
let time = rest.strip_prefix('T').unwrap_or(rest);
let (hours, time) = split_unit(time, 'H');
let (minutes, time) = split_unit(time, 'M');
let (seconds, _) = split_unit(time, 'S');
let total = days * 24 * 60 + hours * 60 + minutes + seconds / 60;
(total > 0).then_some(total)
}
/// Splits a leading `<number><unit>` off a duration, if the next unit matches.
fn split_unit(text: &str, unit: char) -> (i64, &str) {
let Some(index) = text.find(unit) else {
return (0, text);
};
match text[..index].parse::<i64>() {
Ok(value) => (value, &text[index + 1..]),
Err(_) => (0, text),
}
}
/// The value of a property on the master event — the one without a RECURRENCE-ID.
fn master_property(calendar: &Calendar, name: &str) -> Option<String> {
calendar
.properties("VEVENT", name)
.next()
.map(|property| property.value.to_string())
}
fn status_of(calendar: &Calendar) -> &'static str {
match master_property(calendar, "STATUS").as_deref() {
Some(value) if value.eq_ignore_ascii_case("TENTATIVE") => "tentative",
Some(value) if value.eq_ignore_ascii_case("CANCELLED") => "cancelled",
_ => "confirmed",
}
}
fn response_status(partstat: &str) -> &'static str {
match partstat.to_ascii_uppercase().as_str() {
"ACCEPTED" => "accepted",
"DECLINED" => "declined",
"TENTATIVE" => "tentative",
_ => "needsAction",
}
}
fn strip_scheme(value: &str) -> &str {
value.split_once(':').map_or(value, |(_, rest)| rest)
}
/// Converts a stored DTSTART or DTEND back into Google's representation.
fn google_time(calendar: &Calendar, name: &str) -> Result<serde_json::Value, ConvertError> {
let property =
calendar
.properties("VEVENT", name)
.next()
.ok_or_else(|| ConvertError::NoStart {
id: "<local>".to_string(),
})?;
let value = property.value;
let mut time = serde_json::Map::new();
if property.param("VALUE") == Some("DATE") || value.len() == 8 {
time.insert("date".into(), hyphenate(value).into());
return Ok(serde_json::Value::Object(time));
}
// A zoned local time has to become an absolute instant, which needs the zone
// to resolve the offset for that date — the same conversion as the inbound
// direction, in reverse.
let zone_name = property.param("TZID");
let zone = zone_name
.and_then(|name| TimeZone::get(name).ok())
.unwrap_or(TimeZone::UTC);
let civil = parse_basic(value, "DTSTART")?;
let zoned = civil
.to_zoned(zone)
.map_err(|source| ConvertError::BadTime {
id: "<local>".to_string(),
field: "DTSTART",
value: value.to_string(),
source,
})?;
time.insert("dateTime".into(), zoned.timestamp().to_string().into());
if let Some(zone_name) = zone_name {
time.insert("timeZone".into(), zone_name.into());
}
Ok(serde_json::Value::Object(time))
}
/// Parses `YYYYMMDDTHHMMSS`, with or without a trailing Z.
fn parse_basic(value: &str, field: &'static str) -> Result<jiff::civil::DateTime, ConvertError> {
let trimmed = value.trim_end_matches('Z');
jiff::civil::DateTime::strptime("%Y%m%dT%H%M%S", trimmed).map_err(|source| {
ConvertError::BadTime {
id: "<local>".to_string(),
field,
value: value.to_string(),
source,
}
})
}
fn hyphenate(basic: &str) -> String {
if basic.len() != 8 {
return basic.to_string();
}
format!("{}-{}-{}", &basic[..4], &basic[4..6], &basic[6..])
}
/// Reverses the escaping RFC 5545 requires of TEXT values.
fn unescape_text(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next() {
Some('n' | 'N') => out.push('\n'),
Some(escaped) => out.push(escaped),
None => out.push('\\'),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> Vec<Event> {
serde_json::from_str(json).expect("fixture should deserialise")
}
/// Taken verbatim from a real calendar: the offset is +02:00 while the zone
/// is Asia/Karachi, which is +05:00.
const KARACHI_MASTER: &str = r#"[{
"id": "master1",
"status": "confirmed",
"summary": "Flux Dev Meeting",
"start": {"dateTime": "2023-10-31T13:00:00+02:00", "timeZone": "Asia/Karachi"},
"end": {"dateTime": "2023-10-31T14:00:00+02:00", "timeZone": "Asia/Karachi"},
"recurrence": ["RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=1;BYDAY=TU"],
"iCalUID": "master1@google.com",
"sequence": 1,
"updated": "2024-06-18T10:04:30.544Z"
}]"#;
/// The instant is 11:00Z; under Asia/Karachi that is 16:00, not the 13:00
/// the offset in the payload suggests.
#[test]
fn a_recurring_start_is_converted_into_its_own_zone() {
let groups = group_by_uid(parse(KARACHI_MASTER));
let ics = to_ical("master1@google.com", &groups["master1@google.com"]).expect("convert");
assert!(
ics.contains("DTSTART;TZID=Asia/Karachi:20231031T160000"),
"{ics}"
);
assert!(
ics.contains("DTEND;TZID=Asia/Karachi:20231031T170000"),
"{ics}"
);
}
#[test]
fn recurrence_lines_pass_straight_through() {
let groups = group_by_uid(parse(KARACHI_MASTER));
let ics = to_ical("master1@google.com", &groups["master1@google.com"]).expect("convert");
assert!(ics.contains("RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=1;BYDAY=TU"));
}
/// A one-off needs no zone to expand in, so UTC keeps it unambiguous and
/// avoids referencing a VTIMEZONE that is not there.
#[test]
fn a_single_event_is_written_in_utc() {
let json = r#"[{
"id": "one",
"status": "confirmed",
"summary": "Dentist",
"start": {"dateTime": "2026-01-08T14:30:00+02:00", "timeZone": "Asia/Jerusalem"},
"end": {"dateTime": "2026-01-08T15:30:00+02:00", "timeZone": "Asia/Jerusalem"},
"iCalUID": "one@google.com",
"updated": "2026-01-01T00:00:00.000Z"
}]"#;
let groups = group_by_uid(parse(json));
let ics = to_ical("one@google.com", &groups["one@google.com"]).expect("convert");
assert!(ics.contains("DTSTART:20260108T123000Z"), "{ics}");
assert!(!ics.contains("TZID"), "{ics}");
}
#[test]
fn an_all_day_event_keeps_its_date_form() {
let json = r#"[{
"id": "allday",
"status": "confirmed",
"summary": "Holiday",
"start": {"date": "2024-04-08"},
"end": {"date": "2024-04-09"},
"iCalUID": "allday@google.com",
"updated": "2024-01-01T00:00:00.000Z"
}]"#;
let groups = group_by_uid(parse(json));
let ics = to_ical("allday@google.com", &groups["allday@google.com"]).expect("convert");
assert!(ics.contains("DTSTART;VALUE=DATE:20240408"), "{ics}");
assert!(ics.contains("DTEND;VALUE=DATE:20240409"), "{ics}");
}
const WITH_EXCEPTIONS: &str = r#"[
{
"id": "m", "status": "confirmed", "summary": "Weekly",
"start": {"dateTime": "2024-03-05T12:00:00+02:00", "timeZone": "Asia/Jerusalem"},
"end": {"dateTime": "2024-03-05T13:00:00+02:00", "timeZone": "Asia/Jerusalem"},
"recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=TU"],
"iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z"
},
{
"id": "m_20240319T100000Z", "status": "cancelled",
"recurringEventId": "m",
"originalStartTime": {"dateTime": "2024-03-19T12:00:00+02:00", "timeZone": "Asia/Jerusalem"},
"iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z"
},
{
"id": "m_20240326T100000Z", "status": "confirmed", "summary": "Weekly (moved)",
"recurringEventId": "m",
"originalStartTime": {"dateTime": "2024-03-26T12:00:00+02:00", "timeZone": "Asia/Jerusalem"},
"start": {"dateTime": "2024-03-26T15:00:00+02:00", "timeZone": "Asia/Jerusalem"},
"end": {"dateTime": "2024-03-26T16:00:00+02:00", "timeZone": "Asia/Jerusalem"},
"iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z"
}
]"#;
/// Exceptions share the master's iCalUID, so one file holds the series.
#[test]
fn exceptions_group_with_their_master() {
let groups = group_by_uid(parse(WITH_EXCEPTIONS));
assert_eq!(groups.len(), 1);
let group = &groups["m@google.com"];
assert!(group.master.is_some());
assert_eq!(group.overrides.len(), 2);
}
/// A deleted occurrence is an absence rather than an event.
#[test]
fn a_cancelled_occurrence_becomes_an_exdate_on_the_master() {
let groups = group_by_uid(parse(WITH_EXCEPTIONS));
let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert");
assert!(
ics.contains("EXDATE;TZID=Asia/Jerusalem:20240319T120000"),
"{ics}"
);
}
#[test]
fn a_moved_occurrence_becomes_a_recurrence_id_event() {
let groups = group_by_uid(parse(WITH_EXCEPTIONS));
let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert");
assert!(
ics.contains("RECURRENCE-ID;TZID=Asia/Jerusalem:20240326T120000"),
"{ics}"
);
assert!(
ics.contains("DTSTART;TZID=Asia/Jerusalem:20240326T150000"),
"{ics}"
);
assert!(ics.contains("SUMMARY:Weekly (moved)"));
}
/// Whatever we render has to survive our own parser, or nothing downstream works.
#[test]
fn the_output_parses_as_icalendar() {
let groups = group_by_uid(parse(WITH_EXCEPTIONS));
let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert");
let calendar = Calendar::parse(&ics).expect("output should be valid iCalendar");
assert_eq!(calendar.uid(), Some("m@google.com"));
// Master plus the one moved occurrence; the cancelled one is an EXDATE.
assert_eq!(calendar.properties("VEVENT", "UID").count(), 2);
assert_eq!(calendar.properties("VEVENT", "RECURRENCE-ID").count(), 1);
}
#[test]
fn trigger_durations_become_minutes() {
assert_eq!(minutes_before("-PT15M"), Some(15));
assert_eq!(minutes_before("-PT1H"), Some(60));
assert_eq!(minutes_before("-P1D"), Some(1440));
assert_eq!(minutes_before("-P1DT2H30M"), Some(1590));
// Google has no way to express these, so they are dropped rather than guessed.
assert_eq!(minutes_before("PT15M"), None, "after the start");
assert_eq!(minutes_before("-PT0S"), None, "at the start");
assert_eq!(minutes_before("20260101T090000Z"), None, "absolute");
}
/// An alarm must survive a round trip, since alarms are never stripped.
#[test]
fn alarms_survive_conversion_to_google() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:a@calcalist\r\nDTSTART:20260915T090000Z\r\nDTEND:20260915T100000Z\r\nSUMMARY:With alarm\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let calendar = Calendar::parse(ics).expect("parse");
let json = to_google(&calendar).expect("convert");
let reminders = &json["reminders"];
assert_eq!(reminders["useDefault"], serde_json::json!(false));
assert_eq!(reminders["overrides"][0]["minutes"], serde_json::json!(15));
assert_eq!(
reminders["overrides"][0]["method"],
serde_json::json!("popup")
);
}
#[test]
fn google_reminders_become_alarms() {
let json = r#"[{
"id": "r", "status": "confirmed", "summary": "Reminded",
"start": {"dateTime": "2026-09-15T09:00:00Z"},
"end": {"dateTime": "2026-09-15T10:00:00Z"},
"iCalUID": "r@google.com", "updated": "2026-01-01T00:00:00.000Z",
"reminders": {"useDefault": false, "overrides": [{"method": "popup", "minutes": 30}]}
}]"#;
let groups = group_by_uid(parse(json));
let ics = to_ical("r@google.com", &groups["r@google.com"]).expect("convert");
assert!(ics.contains("BEGIN:VALARM"), "{ics}");
assert!(ics.contains("TRIGGER:-PT30M"), "{ics}");
}
/// useDefault is a property of the calendar, not the event, so there is no
/// alarm to carry.
#[test]
fn default_reminders_produce_no_alarm() {
let json = r#"[{
"id": "d", "status": "confirmed", "summary": "Default",
"start": {"dateTime": "2026-09-15T09:00:00Z"},
"end": {"dateTime": "2026-09-15T10:00:00Z"},
"iCalUID": "d@google.com", "updated": "2026-01-01T00:00:00.000Z",
"reminders": {"useDefault": true}
}]"#;
let groups = group_by_uid(parse(json));
let ics = to_ical("d@google.com", &groups["d@google.com"]).expect("convert");
assert!(!ics.contains("VALARM"), "{ics}");
}
#[test]
fn attendees_and_recurrence_reach_google() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:b@calcalist\r\nDTSTART;TZID=Asia/Jerusalem:20260312T161500\r\nRRULE:FREQ=WEEKLY;BYDAY=TH\r\nSUMMARY:Series\r\nATTENDEE;CN=Guest;PARTSTAT=ACCEPTED:mailto:guest@example.com\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let calendar = Calendar::parse(ics).expect("parse");
let json = to_google(&calendar).expect("convert");
assert_eq!(
json["recurrence"][0],
serde_json::json!("RRULE:FREQ=WEEKLY;BYDAY=TH")
);
assert_eq!(
json["attendees"][0]["email"],
serde_json::json!("guest@example.com")
);
assert_eq!(
json["attendees"][0]["responseStatus"],
serde_json::json!("accepted")
);
assert_eq!(
json["start"]["timeZone"],
serde_json::json!("Asia/Jerusalem")
);
// 16:15 Jerusalem in March is +02:00, so 14:15Z.
assert!(
json["start"]["dateTime"]
.as_str()
.expect("dateTime")
.starts_with("2026-03-12T14:15:00"),
"{}",
json["start"]["dateTime"]
);
}
#[test]
fn an_all_day_event_returns_as_a_date() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:c@calcalist\r\nDTSTART;VALUE=DATE:20261225\r\nDTEND;VALUE=DATE:20261226\r\nSUMMARY:Christmas\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let calendar = Calendar::parse(ics).expect("parse");
let json = to_google(&calendar).expect("convert");
assert_eq!(json["start"]["date"], serde_json::json!("2026-12-25"));
assert_eq!(json["end"]["date"], serde_json::json!("2026-12-26"));
}
/// Converts a captured API response and checks every group survives.
///
/// Ignored by default: it needs a real payload, and calendar data is personal,
/// so none is committed. Run against a capture with:
/// CALCALIST_GOOGLE_FIXTURE=/path/to/events.json cargo test -- --ignored
#[test]
#[ignore = "requires a captured API response"]
fn a_captured_response_converts_cleanly() {
let Ok(path) = std::env::var("CALCALIST_GOOGLE_FIXTURE") else {
panic!("set CALCALIST_GOOGLE_FIXTURE to a captured events response");
};
let body = std::fs::read_to_string(&path).expect("fixture should be readable");
let response: serde_json::Value =
serde_json::from_str(&body).expect("fixture should be JSON");
let events: Vec<Event> =
serde_json::from_value(response["items"].clone()).expect("items should deserialise");
let total = events.len();
let groups = group_by_uid(events);
let mut converted = 0;
for (uid, group) in &groups {
if group.master.is_none() {
// An exception whose master is outside this page; the caller
// merges those into the stored file rather than rendering alone.
continue;
}
let ics = to_ical(uid, group).unwrap_or_else(|error| panic!("{uid}: {error}"));
Calendar::parse(&ics)
.unwrap_or_else(|error| panic!("{uid} produced invalid iCal: {error}\n{ics}"));
converted += 1;
}
println!(
"{total} event(s) in {} group(s); {converted} rendered",
groups.len()
);
assert!(converted > 0, "nothing was converted");
}
#[test]
fn text_values_are_escaped() {
let json = r#"[{
"id": "x", "status": "confirmed",
"summary": "Lunch, then talk; bring notes",
"start": {"date": "2026-02-02"}, "end": {"date": "2026-02-03"},
"iCalUID": "x@google.com", "updated": "2026-01-01T00:00:00.000Z"
}]"#;
let groups = group_by_uid(parse(json));
let ics = to_ical("x@google.com", &groups["x@google.com"]).expect("convert");
assert!(
ics.contains(r"SUMMARY:Lunch\, then talk\; bring notes"),
"{ics}"
);
}
}