CalCalist/src/mirror.rs

506 lines
19 KiB
Rust
Raw Normal View History

Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
//! Transforming events between a source calendar and its aggregate copy.
//!
//! Mirroring is not a byte copy. The aggregate copy must be *scheduling inert* —
//! writing it must never cause a server to mail invitations or cancellations for
//! a meeting that was already invited from its source. How that is achieved
//! depends on the target backend, which is what [`SchedulingSuppression`] selects.
//!
//! Writing an edit back to the source is the inverse, with one asymmetry: the
//! demoted form has thrown away the live guest list, so the source event itself
//! is used as the donor to put it back. Otherwise editing a meeting's time in the
//! aggregate would silently drop its guests.
use crate::config::SchedulingSuppression;
use crate::ical::Calendar;
/// Records which source an aggregate copy came from.
pub const SOURCE_PROPERTY: &str = "X-CALCALIST-SOURCE";
/// Records the UID the event has in its source calendar.
pub const ORIGIN_UID_PROPERTY: &str = "X-CALCALIST-ORIGIN-UID";
/// Carries the guest list inertly when it cannot be kept as real attendees.
pub const ATTENDEES_PROPERTY: &str = "X-CALCALIST-ATTENDEES";
/// Separates the original description from the appended guest list, so the
/// addition can be found and removed again when writing an edit back.
pub const GUEST_MARKER: &str = "-- guests (calcalist) --";
/// Properties that only ever exist on an aggregate copy.
const OWN_PROPERTIES: &[&str] = &[SOURCE_PROPERTY, ORIGIN_UID_PROPERTY, ATTENDEES_PROPERTY];
/// Live scheduling properties, whose presence is what makes a server send mail.
const SCHEDULING_PROPERTIES: &[&str] = &["ATTENDEE", "ORGANIZER"];
Make an aggregate's own events a first-class mode An aggregate with no default_sink already left events created in it alone, but treated doing so as a failure: it reported a skip per event per cycle saying no sink was configured, as though something had gone wrong. Nothing had. An aggregate is also a calendar, and holding events of its own is a legitimate way to use one. Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local` joins the routing markers, so the mode also works per-event where a default_sink is configured — which was not previously expressible. Unlike every other marker it is deliberately not stripped: the others have done their job once the event reaches its source, whereas this one never leaves, so it has to stay legible for the next cycle to reach the same decision. `local` is therefore a reserved endpoint id, and configuring one is refused. This also fixes a real defect. `retarget` rebuilds the new target from the recorded links, which cover derived events only, so an event belonging to the aggregate itself did not follow the move — it stayed on the calendar being left behind while everything around it moved on, quietly. It is now carried across, since there is nothing to re-derive it from, and the new target's scheduling rule is applied on the way: this is a write to an aggregate like any other, and a guest list carried live onto a server that schedules would mail everyone on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:30:25 +03:00
/// The marker that keeps an event in the aggregate rather than filing it under
/// a source. Reserved: an endpoint may not be given this id.
pub const LOCAL_SINK: &str = "local";
/// Whether a routing hint asks for the event to stay where it is.
pub fn is_local_sink(hint: &str) -> bool {
hint.eq_ignore_ascii_case(LOCAL_SINK)
}
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
/// Builds the aggregate copy of a source event.
pub fn to_aggregate(
source: &Calendar,
aggregate_uid: &str,
source_id: &str,
source_uid: &str,
owner: Option<&str>,
suppression: SchedulingSuppression,
) -> Calendar {
let mut mirrored = source.clone();
mirrored.set_uid(aggregate_uid);
mirrored.remove_properties("VEVENT", OWN_PROPERTIES);
mirrored.add_property("VEVENT", SOURCE_PROPERTY, source_id);
mirrored.add_property("VEVENT", ORIGIN_UID_PROPERTY, source_uid);
if suppression == SchedulingSuppression::Native {
return mirrored;
}
demote_attendees(&mut mirrored, owner);
mirrored
}
Make an aggregate's own events a first-class mode An aggregate with no default_sink already left events created in it alone, but treated doing so as a failure: it reported a skip per event per cycle saying no sink was configured, as though something had gone wrong. Nothing had. An aggregate is also a calendar, and holding events of its own is a legitimate way to use one. Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local` joins the routing markers, so the mode also works per-event where a default_sink is configured — which was not previously expressible. Unlike every other marker it is deliberately not stripped: the others have done their job once the event reaches its source, whereas this one never leaves, so it has to stay legible for the next cycle to reach the same decision. `local` is therefore a reserved endpoint id, and configuring one is refused. This also fixes a real defect. `retarget` rebuilds the new target from the recorded links, which cover derived events only, so an event belonging to the aggregate itself did not follow the move — it stayed on the calendar being left behind while everything around it moved on, quietly. It is now carried across, since there is nothing to re-derive it from, and the new target's scheduling rule is applied on the way: this is a write to an aggregate like any other, and a guest list carried live onto a server that schedules would mail everyone on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:30:25 +03:00
/// Applies a target's scheduling rule to an event that is not a mirror.
///
/// An event living only in the aggregate still has to obey the rule that writes
/// to an aggregate never emit scheduling mail — moving one between calendars is
/// as capable of mailing a guest list as mirroring is. It carries no provenance,
/// having none, so this is the demotion alone.
pub fn make_inert(
calendar: &Calendar,
owner: Option<&str>,
suppression: SchedulingSuppression,
) -> Calendar {
let mut inert = calendar.clone();
if suppression == SchedulingSuppression::None {
demote_attendees(&mut inert, owner);
}
inert
}
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
/// Removes the live guest list, keeping its information in inert form.
fn demote_attendees(calendar: &mut Calendar, owner: Option<&str>) {
let guests: Vec<String> = calendar
.properties("VEVENT", "ATTENDEE")
.map(|attendee| {
let name = attendee
.param("CN")
.unwrap_or_else(|| address(attendee.value));
match attendee.param("PARTSTAT") {
Some(status) => format!("{name} <{}> ({status})", address(attendee.value)),
None => format!("{name} <{}>", address(attendee.value)),
}
})
.collect();
// A meeting the owner declined must not read as busy in the aggregate.
if owner.is_some_and(|owner| declined(calendar, owner)) {
calendar.set_property("VEVENT", "TRANSP", "TRANSPARENT");
}
if !guests.is_empty() {
calendar.add_property(
"VEVENT",
ATTENDEES_PROPERTY,
&escape_text(&guests.join(", ")),
);
append_guests_to_description(calendar, &guests);
}
calendar.remove_properties("VEVENT", SCHEDULING_PROPERTIES);
}
/// Whether `owner` is an attendee who has declined.
fn declined(calendar: &Calendar, owner: &str) -> bool {
calendar.properties("VEVENT", "ATTENDEE").any(|attendee| {
address(attendee.value).eq_ignore_ascii_case(owner)
&& attendee.param("PARTSTAT") == Some("DECLINED")
})
}
fn append_guests_to_description(calendar: &mut Calendar, guests: &[String]) {
let existing = calendar
.properties("VEVENT", "DESCRIPTION")
.next()
.map(|property| property.value.to_string())
.unwrap_or_default();
let block = format!("{GUEST_MARKER}\\n{}", escape_text(&guests.join("\n")));
let combined = if existing.is_empty() {
block
} else {
format!("{existing}\\n\\n{block}")
};
calendar.set_property("VEVENT", "DESCRIPTION", &combined);
}
Let an event choose which source it is filed under An event created in an aggregate went to the configured default_sink and nowhere else, so with several writable sources there was no way to say which calendar a new event belonged in. A line reading @endpoint-id in the description now picks the source, and a matching CATEGORIES value does too. The description rather than the title because every calendar client exposes a notes field and editing it does not disfigure the event's name; CATEGORIES as well because that is the field iCalendar intends, even though many mobile clients hide it. The marker is stripped before the event reaches the calendar, being calcalist's bookkeeping rather than content. A marker naming something that is not a writable source of that aggregate is refused and reported, not redirected to the default: a typo should not quietly file an event in the wrong calendar. A bare address in prose is not a marker either, since a marker must be a line of its own. Also covers the shapes beyond many-into-one: a source feeding several aggregates, several aggregates sharing one target, a cycle between two aggregates, a delete cascading across aggregates, and competing edits arriving through two aggregates at once — the last being caught by the existing conflict detection rather than silently overwriting. Error display no longer repeats itself; thiserror already prints the cause chain. Verified live against real accounts: two Google calendars aggregating into a Posteo calendar, an edit in the aggregate reaching the originating Google calendar, an event routed to a chosen source by its description marker, and a deletion propagating from the aggregate through to Google. 129 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:29:02 +03:00
/// Marks which source an event created in the aggregate should be filed under.
///
/// A line of its own in the description, `@endpoint-id`. The description is used
/// rather than the title because every calendar client exposes a notes field and
/// editing it does not disfigure the event's name, and rather than CATEGORIES
/// because many mobile clients do not surface categories at all — though a
/// matching category is honoured too, that being the field iCalendar intends.
pub const ROUTE_MARKER: char = '@';
/// The source an event asks to be filed under, if it names one.
pub fn routing_hint(calendar: &Calendar) -> Option<String> {
if let Some(description) = calendar
.properties("VEVENT", "DESCRIPTION")
.next()
.map(|property| property.value)
&& let Some(hint) = description_hint(description)
{
return Some(hint);
}
calendar
.properties("VEVENT", "CATEGORIES")
.flat_map(|property| {
property
.value
.split(',')
.map(|category| category.trim().to_string())
.collect::<Vec<_>>()
})
.find(|category| !category.is_empty())
}
/// Finds a line consisting only of `@name`. Requiring the whole line keeps an
/// address written in prose from being mistaken for a routing instruction.
fn description_hint(description: &str) -> Option<String> {
description
.split("\\n")
.map(str::trim)
.find_map(|line| line.strip_prefix(ROUTE_MARKER))
.filter(|name| !name.is_empty() && !name.contains(char::is_whitespace))
.map(str::to_string)
}
/// Removes the routing instruction, which is calcalist's own bookkeeping and has
/// no business appearing in the calendar the event lands in.
pub fn strip_routing_hint(calendar: &mut Calendar, hint: &str) {
let categories: Vec<String> = calendar
.properties("VEVENT", "CATEGORIES")
.map(|property| property.value.to_string())
.collect();
if !categories.is_empty() {
calendar.remove_properties("VEVENT", &["CATEGORIES"]);
let kept: Vec<String> = categories
.iter()
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|category| !category.eq_ignore_ascii_case(hint) && !category.is_empty())
.map(str::to_string)
.collect();
if !kept.is_empty() {
calendar.add_property("VEVENT", "CATEGORIES", &kept.join(","));
}
}
let Some(description) = calendar
.properties("VEVENT", "DESCRIPTION")
.next()
.map(|property| property.value.to_string())
else {
return;
};
let marker = format!("{ROUTE_MARKER}{hint}");
let kept: Vec<&str> = description
.split("\\n")
.filter(|line| line.trim() != marker)
.collect();
if kept.iter().all(|line| line.trim().is_empty()) {
calendar.remove_properties("VEVENT", &["DESCRIPTION"]);
} else {
calendar.set_property("VEVENT", "DESCRIPTION", &kept.join("\\n"));
}
}
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
/// Rebuilds a source-side event from an edited aggregate copy.
///
/// `donor` is the event as it currently stands in the source calendar, when
/// there is one. It supplies the properties the mirror transform removed, so an
/// edit made in the aggregate cannot silently strip a meeting's guests. Routing
/// a newly created event has no donor: nothing was stripped from it.
pub fn to_source(edited: &Calendar, donor: Option<&Calendar>, source_uid: &str) -> Calendar {
let mut restored = edited.clone();
restored.set_uid(source_uid);
restored.remove_properties("VEVENT", OWN_PROPERTIES);
strip_guest_block(&mut restored);
// If the mirror kept live attendees, the edit owns them. If it demoted them,
// they are missing here and must come back from the source.
if let Some(donor) = donor
&& restored.properties("VEVENT", "ATTENDEE").next().is_none()
{
for line in donor.property_lines("VEVENT", "ORGANIZER") {
restored.add_raw_property("VEVENT", line);
}
for line in donor.property_lines("VEVENT", "ATTENDEE") {
restored.add_raw_property("VEVENT", line);
}
}
restored
}
/// Removes the appended guest block from DESCRIPTION, leaving any real text.
fn strip_guest_block(calendar: &mut Calendar) {
let Some(description) = calendar
.properties("VEVENT", "DESCRIPTION")
.next()
.map(|property| property.value.to_string())
else {
return;
};
let Some(index) = description.find(GUEST_MARKER) else {
return;
};
// Strip exactly the separator this transform inserted. Trimming a character
// set here would eat a trailing "n" from real text ("Plan" -> "Pla").
let before = &description[..index];
let kept = before.strip_suffix("\\n\\n").unwrap_or(before).to_string();
if kept.is_empty() {
calendar.remove_properties("VEVENT", &["DESCRIPTION"]);
} else {
calendar.set_property("VEVENT", "DESCRIPTION", &kept);
}
}
/// Strips a `mailto:` (or other) scheme from a calendar user address.
fn address(value: &str) -> &str {
value.split_once(':').map_or(value, |(_, rest)| rest)
}
/// Escapes a value for an iCalendar TEXT property, per RFC 5545 section 3.3.11.
Pull Google calendars into their local vdirs The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - 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 reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:13:26 +03:00
pub(crate) fn escape_text(value: &str) -> String {
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => escaped.push_str(r"\\"),
';' => escaped.push_str(r"\;"),
',' => escaped.push_str(r"\,"),
'\n' => escaped.push_str(r"\n"),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
const OWNER: &str = "me@example.com";
fn source_event(extra: &str) -> Calendar {
let text = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:event-1@example.com\r\nDTSTART:20260910T090000Z\r\nSUMMARY:Weekly sync\r\n{extra}BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
);
Calendar::parse(&text).expect("fixture should parse")
}
const GUESTS: &str = concat!(
"ORGANIZER;CN=Boss:mailto:boss@example.com\r\n",
"ATTENDEE;CN=Me;PARTSTAT=ACCEPTED:mailto:me@example.com\r\n",
"ATTENDEE;CN=Them;PARTSTAT=NEEDS-ACTION:mailto:them@example.com\r\n",
);
fn mirror(source: &Calendar, suppression: SchedulingSuppression) -> Calendar {
to_aggregate(
source,
"deadbeef@calcalist",
"work",
"event-1@example.com",
Some(OWNER),
suppression,
)
}
#[test]
fn a_mirror_records_where_it_came_from() {
let mirrored = mirror(&source_event(""), SchedulingSuppression::Native);
assert_eq!(mirrored.uid(), Some("deadbeef@calcalist"));
assert_eq!(
mirrored
.properties("VEVENT", SOURCE_PROPERTY)
.next()
.map(|p| p.value),
Some("work")
);
assert_eq!(
mirrored
.properties("VEVENT", ORIGIN_UID_PROPERTY)
.next()
.map(|p| p.value),
Some("event-1@example.com")
);
}
/// Google can suppress notification, so the guest list survives intact.
#[test]
fn a_suppressible_backend_keeps_real_attendees() {
let mirrored = mirror(&source_event(GUESTS), SchedulingSuppression::Native);
assert_eq!(mirrored.properties("VEVENT", "ATTENDEE").count(), 2);
assert_eq!(mirrored.properties("VEVENT", "ORGANIZER").count(), 1);
assert_eq!(mirrored.properties("VEVENT", ATTENDEES_PROPERTY).count(), 0);
}
/// CalDAV cannot, so the live properties must go.
#[test]
fn an_unsuppressible_backend_emits_no_live_scheduling_properties() {
let mirrored = mirror(&source_event(GUESTS), SchedulingSuppression::None);
assert_eq!(mirrored.properties("VEVENT", "ATTENDEE").count(), 0);
assert_eq!(mirrored.properties("VEVENT", "ORGANIZER").count(), 0);
let output = mirrored.to_ics();
assert!(!output.contains("\r\nATTENDEE"));
assert!(!output.contains("\r\nORGANIZER"));
}
#[test]
fn the_demoted_guest_list_is_still_readable() {
let mirrored = mirror(&source_event(GUESTS), SchedulingSuppression::None);
let inert = mirrored
.properties("VEVENT", ATTENDEES_PROPERTY)
.next()
.expect("guest list carried")
.value;
assert!(inert.contains("Me <me@example.com> (ACCEPTED)"));
assert!(inert.contains("Them <them@example.com> (NEEDS-ACTION)"));
let description = mirrored
.properties("VEVENT", "DESCRIPTION")
.next()
.expect("description added")
.value;
assert!(description.contains(GUEST_MARKER));
}
/// A meeting the owner declined should read as free, not busy.
#[test]
fn declining_marks_the_mirror_transparent() {
let declined_by_owner = concat!(
"ATTENDEE;CN=Me;PARTSTAT=DECLINED:mailto:me@example.com\r\n",
"ATTENDEE;CN=Them;PARTSTAT=ACCEPTED:mailto:them@example.com\r\n",
);
let mirrored = mirror(
&source_event(declined_by_owner),
SchedulingSuppression::None,
);
assert_eq!(
mirrored
.properties("VEVENT", "TRANSP")
.next()
.map(|p| p.value),
Some("TRANSPARENT")
);
}
/// Someone else declining says nothing about the owner's availability.
#[test]
fn another_guest_declining_does_not_free_the_owner() {
let other_declined = concat!(
"ATTENDEE;CN=Me;PARTSTAT=ACCEPTED:mailto:me@example.com\r\n",
"ATTENDEE;CN=Them;PARTSTAT=DECLINED:mailto:them@example.com\r\n",
);
let mirrored = mirror(&source_event(other_declined), SchedulingSuppression::None);
assert_eq!(mirrored.properties("VEVENT", "TRANSP").count(), 0);
}
#[test]
fn alarms_are_never_touched() {
for suppression in [SchedulingSuppression::Native, SchedulingSuppression::None] {
let mirrored = mirror(&source_event(GUESTS), suppression);
assert!(mirrored.to_ics().contains("BEGIN:VALARM"));
assert!(mirrored.to_ics().contains("TRIGGER:-PT15M"));
}
}
#[test]
fn mirroring_twice_is_stable() {
let source = source_event(GUESTS);
let once = mirror(&source, SchedulingSuppression::None);
let twice = mirror(&source, SchedulingSuppression::None);
assert_eq!(once.to_ics(), twice.to_ics());
}
#[test]
fn writing_back_restores_the_source_uid_and_drops_our_properties() {
let source = source_event(GUESTS);
let mirrored = mirror(&source, SchedulingSuppression::None);
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
assert_eq!(back.uid(), Some("event-1@example.com"));
for property in OWN_PROPERTIES {
assert_eq!(back.properties("VEVENT", property).count(), 0, "{property}");
}
}
/// The demoted mirror has no guests to give back, so editing it must not
/// silently strip the meeting's attendees.
#[test]
fn writing_back_a_demoted_mirror_recovers_the_guest_list() {
let source = source_event(GUESTS);
let mut mirrored = mirror(&source, SchedulingSuppression::None);
mirrored.set_property("VEVENT", "SUMMARY", "Weekly sync (moved)");
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
assert_eq!(back.properties("VEVENT", "ATTENDEE").count(), 2);
assert_eq!(back.properties("VEVENT", "ORGANIZER").count(), 1);
// The edit itself survives.
assert_eq!(
back.properties("VEVENT", "SUMMARY").next().map(|p| p.value),
Some("Weekly sync (moved)")
);
// And the parameters came back with them.
let attendee = back
.properties("VEVENT", "ATTENDEE")
.find(|a| a.value.contains("them@"))
.expect("guest restored");
assert_eq!(attendee.param("PARTSTAT"), Some("NEEDS-ACTION"));
}
#[test]
fn writing_back_removes_the_appended_guest_block() {
let source = source_event(GUESTS);
let mirrored = mirror(&source, SchedulingSuppression::None);
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
assert_eq!(back.properties("VEVENT", "DESCRIPTION").count(), 0);
}
#[test]
fn a_real_description_survives_the_round_trip() {
let with_description = format!("DESCRIPTION:Bring the plan\r\n{GUESTS}");
let source = source_event(&with_description);
let mirrored = mirror(&source, SchedulingSuppression::None);
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
assert_eq!(
back.properties("VEVENT", "DESCRIPTION")
.next()
.map(|p| p.value),
Some("Bring the plan")
);
}
/// Regression: trimming a character set would truncate text ending in "n".
#[test]
fn a_description_ending_in_n_is_not_truncated() {
let with_description = format!("DESCRIPTION:Bring the plan\r\n{GUESTS}");
let source = source_event(&with_description);
let mirrored = mirror(&source, SchedulingSuppression::None);
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
let description = back
.properties("VEVENT", "DESCRIPTION")
.next()
.expect("description kept")
.value;
assert!(
description.ends_with("plan"),
"truncated to {description:?}"
);
}
#[test]
fn text_escaping_follows_the_specification() {
assert_eq!(escape_text("a,b;c\\d\ne"), r"a\,b\;c\\d\ne");
}
}