Add the events API
One path with an EditScope on the write verbs. v1 had a second parallel tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating the non-series handlers, dispatching on string literals in 53 places where a typo was a runtime fallthrough. What a scoped edit means to the stored .ics lives in runway-core::series, pure and tested without a server, because that is the subtle part and v1 shipped it with no coverage at all. Editing one occurrence writes an override and no EXDATE: an EXDATE says the occurrence does not happen, an override says it happens differently, and writing both is contradictory. Deleting one writes the EXDATE and removes any override that named it. Splitting a series divides its bound rather than dropping it. Six weekly occurrences split at the third become two plus four, not two plus forever -- the count is what the person asked for and it should survive being cut. Overrides after the split move to the new series; moving a whole series shifts its overrides' RECURRENCE-IDs by the same amount instead of leaving them pointing at occurrences that no longer exist. Every write states a precondition. There is no unconditional path: an update without an ETag is refused, and a stale one is a conflict rather than a silent overwrite. UIDs are minted server-side, because a client-supplied one could collide with and replace an unrelated event. Reads use time-range and fan out across calendars concurrently. Zones that cannot be resolved are reported in the response instead of being rendered as though they were fine. Two tests found real bugs: sub-second timestamps cannot survive iCalendar's one-second resolution, and splitting at the first occurrence was dropping the recurrence rule and quietly turning a series into a single event.
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
//!
|
||||
//! - `model` (default): the types below. No heavy dependencies.
|
||||
//! - `ical`: parsing and serialising `VCALENDAR` data.
|
||||
//! - `recurrence`: expanding `RRULE`s into concrete occurrences.
|
||||
//! - `recurrence`: expanding `RRULE`s into concrete occurrences, and the
|
||||
//! series-editing rules built on them.
|
||||
|
||||
pub mod model;
|
||||
|
||||
@@ -19,4 +20,11 @@ pub mod ical;
|
||||
#[cfg(feature = "recurrence")]
|
||||
pub mod recurrence;
|
||||
|
||||
/// Editing and deleting parts of a recurring series.
|
||||
///
|
||||
/// Needs `recurrence` because bounding a rule means resolving occurrences to
|
||||
/// instants, which needs the zone database.
|
||||
#[cfg(feature = "recurrence")]
|
||||
pub mod series;
|
||||
|
||||
pub use model::*;
|
||||
|
||||
@@ -114,7 +114,7 @@ impl VEvent {
|
||||
|
||||
/// A new event with a caller-supplied UID.
|
||||
pub fn with_uid(uid: impl Into<String>, dtstart: CalendarDateTime) -> Self {
|
||||
let now = Utc::now();
|
||||
let now = super::now_to_second();
|
||||
Self {
|
||||
uid: uid.into(),
|
||||
dtstamp: now,
|
||||
|
||||
@@ -16,6 +16,18 @@ mod property;
|
||||
mod timezone;
|
||||
|
||||
pub use alarm::{AlarmAction, AlarmTrigger, TriggerRelation, VAlarm};
|
||||
|
||||
/// The current instant, truncated to the second.
|
||||
///
|
||||
/// RFC 5545 date-times have one-second resolution. A timestamp carrying
|
||||
/// fractions of a second is a value the wire format cannot hold, so it changes
|
||||
/// the first time it is written and then never compares equal to itself again —
|
||||
/// the same trap that made a stored session differ from the one just created.
|
||||
pub fn now_to_second() -> chrono::DateTime<chrono::Utc> {
|
||||
use chrono::SubsecRound;
|
||||
chrono::Utc::now().trunc_subsecs(0)
|
||||
}
|
||||
|
||||
pub use calendar::{RUNWAY_PRODID, VCalendar};
|
||||
pub use datetime::{CalendarDateTime, EventEnd, IcalDuration, InvalidTzId, TzId};
|
||||
pub use event::{
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Editing and deleting parts of a recurring series.
|
||||
//!
|
||||
//! This is the subtle part of a calendar, and the previous iteration shipped it
|
||||
//! with zero automated coverage across 1,165 lines of duplicated
|
||||
//! `/api/calendar/events/series/*` handlers. It is pure here — a resource and
|
||||
//! an instruction in, a resource out — so every case can be tested without a
|
||||
//! server, and the handler above it does nothing but move bytes.
|
||||
//!
|
||||
//! The three scopes mean three different things to the stored `.ics`:
|
||||
//!
|
||||
//! | Scope | Editing | Deleting |
|
||||
//! |---|---|---|
|
||||
//! | this only | add an override with a `RECURRENCE-ID` | add an `EXDATE` |
|
||||
//! | this and future | truncate with `UNTIL`, start a new series | truncate with `UNTIL` |
|
||||
//! | entire series | edit the master in place | remove the resource |
|
||||
//!
|
||||
//! Note that editing one occurrence writes **only** an override, with no
|
||||
//! `EXDATE`. An `EXDATE` says the occurrence does not happen; an override says
|
||||
//! it happens differently. Writing both would be contradictory, and every
|
||||
//! producer in the corpus — DAVx5, Thunderbird, Evolution, Exchange — writes
|
||||
//! the override alone.
|
||||
|
||||
mod rule;
|
||||
|
||||
pub use rule::{RuleError, count_of, set_count, set_until, strip_bounds};
|
||||
|
||||
use crate::model::{CalendarDateTime, EditScope, VCalendar, VEvent};
|
||||
use crate::recurrence::Zones;
|
||||
use chrono::TimeDelta;
|
||||
use thiserror::Error;
|
||||
|
||||
/// What to write back after an edit.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum EditOutcome {
|
||||
/// Replace the resource in place.
|
||||
Replace(VCalendar),
|
||||
/// Replace the resource *and* create a second one.
|
||||
///
|
||||
/// "This and future" is two writes, not one: the original series is
|
||||
/// truncated and a new series takes over from the split point. Both have to
|
||||
/// land, and the caller has to know it is doing two things.
|
||||
Split {
|
||||
existing: VCalendar,
|
||||
new_series: VCalendar,
|
||||
},
|
||||
}
|
||||
|
||||
/// What to do to the resource after a deletion.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DeleteOutcome {
|
||||
/// Write the resource back with the occurrence removed.
|
||||
Replace(VCalendar),
|
||||
/// Remove the resource entirely.
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// Applies an edit to one resource.
|
||||
///
|
||||
/// `updated` is the event as the person edited it. For a scoped edit it
|
||||
/// describes the *occurrence*, and `recurrence_id` says which one.
|
||||
///
|
||||
/// Under [`EditScope::EntireSeries`] the submitted event **is** the master, so
|
||||
/// an absent `RRULE` means the series should stop recurring. That is a real
|
||||
/// edit rather than an omission, and it is the one place the difference
|
||||
/// matters.
|
||||
pub fn apply_edit(
|
||||
resource: &VCalendar,
|
||||
scope: EditScope,
|
||||
recurrence_id: Option<&CalendarDateTime>,
|
||||
updated: &VEvent,
|
||||
zones: Zones,
|
||||
) -> Result<EditOutcome, SeriesError> {
|
||||
let master = resource.master().ok_or(SeriesError::NoMaster)?;
|
||||
|
||||
// A one-off event has no scopes to speak of: every scope means the same
|
||||
// thing, and pretending otherwise would just be a way to get it wrong.
|
||||
if !master.is_recurring() {
|
||||
return Ok(EditOutcome::Replace(replace_master(resource, updated)));
|
||||
}
|
||||
|
||||
match scope {
|
||||
EditScope::EntireSeries => Ok(EditOutcome::Replace(edit_whole_series(
|
||||
resource, master, updated,
|
||||
))),
|
||||
EditScope::ThisOnly => {
|
||||
let at = recurrence_id.ok_or(SeriesError::MissingRecurrenceId)?;
|
||||
Ok(EditOutcome::Replace(edit_one_occurrence(
|
||||
resource, updated, at,
|
||||
)))
|
||||
}
|
||||
EditScope::ThisAndFuture => {
|
||||
let at = recurrence_id.ok_or(SeriesError::MissingRecurrenceId)?;
|
||||
split_series(resource, master, updated, at, zones)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a deletion to one resource.
|
||||
pub fn apply_delete(
|
||||
resource: &VCalendar,
|
||||
scope: EditScope,
|
||||
recurrence_id: Option<&CalendarDateTime>,
|
||||
zones: Zones,
|
||||
) -> Result<DeleteOutcome, SeriesError> {
|
||||
let master = resource.master().ok_or(SeriesError::NoMaster)?;
|
||||
|
||||
if !master.is_recurring() || scope == EditScope::EntireSeries {
|
||||
return Ok(DeleteOutcome::Remove);
|
||||
}
|
||||
|
||||
let at = recurrence_id.ok_or(SeriesError::MissingRecurrenceId)?;
|
||||
let target = zones.instant(at);
|
||||
|
||||
let mut out = resource.clone();
|
||||
match scope {
|
||||
EditScope::ThisOnly => {
|
||||
for event in &mut out.events {
|
||||
if event.is_override() {
|
||||
continue;
|
||||
}
|
||||
// An EXDATE in the same form as DTSTART. A date-valued series
|
||||
// excludes by date; a zoned one excludes by zoned time.
|
||||
event.exdate.push(at.clone());
|
||||
event.exdate.sort_by_key(|d| zones.instant(d));
|
||||
event.exdate.dedup();
|
||||
}
|
||||
// An override for the occurrence being deleted is now meaningless.
|
||||
out.events.retain(|e| !is_override_at(e, target, zones));
|
||||
}
|
||||
EditScope::ThisAndFuture => {
|
||||
truncate_before(&mut out, target, zones)?;
|
||||
}
|
||||
EditScope::EntireSeries => unreachable!("handled above"),
|
||||
}
|
||||
|
||||
// Truncating from the very first occurrence leaves a series with nothing
|
||||
// in it, which is a resource that should not exist rather than an empty one.
|
||||
if out
|
||||
.master()
|
||||
.is_none_or(|m| m.rrule.is_none() && m.rdate.is_empty())
|
||||
&& out.events.len() == 1
|
||||
&& scope == EditScope::ThisAndFuture
|
||||
&& zones.instant(&out.events[0].dtstart) >= target
|
||||
{
|
||||
return Ok(DeleteOutcome::Remove);
|
||||
}
|
||||
|
||||
Ok(DeleteOutcome::Replace(out))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ editing --
|
||||
|
||||
/// Replaces a non-recurring event's content, keeping its identity.
|
||||
fn replace_master(resource: &VCalendar, updated: &VEvent) -> VCalendar {
|
||||
let mut out = resource.clone();
|
||||
let uid = out.master().map(|m| m.uid.clone());
|
||||
out.events = vec![with_identity(updated, uid, None)];
|
||||
out
|
||||
}
|
||||
|
||||
/// Edits the master, carrying its overrides along.
|
||||
///
|
||||
/// When the edit moves `DTSTART`, every occurrence moves with it — and the
|
||||
/// `RECURRENCE-ID` on each override, which names an occurrence of the *old*
|
||||
/// pattern, would no longer match anything. They are shifted by the same
|
||||
/// amount rather than dropped: those overrides are somebody's edits, and
|
||||
/// silently discarding them is precisely the behaviour this rewrite exists to
|
||||
/// avoid.
|
||||
fn edit_whole_series(resource: &VCalendar, master: &VEvent, updated: &VEvent) -> VCalendar {
|
||||
let shift = updated.dtstart.naive_local() - master.dtstart.naive_local();
|
||||
|
||||
let mut out = resource.clone();
|
||||
out.events = std::iter::once(with_identity(updated, Some(master.uid.clone()), None))
|
||||
.chain(resource.overrides().map(|override_event| {
|
||||
let mut moved = override_event.clone();
|
||||
if shift != TimeDelta::zero() {
|
||||
moved.recurrence_id = moved.recurrence_id.as_ref().map(|id| id.shifted(shift));
|
||||
}
|
||||
moved
|
||||
}))
|
||||
.collect();
|
||||
out
|
||||
}
|
||||
|
||||
/// Adds or replaces the override for one occurrence.
|
||||
fn edit_one_occurrence(resource: &VCalendar, updated: &VEvent, at: &CalendarDateTime) -> VCalendar {
|
||||
let uid = resource.master().map(|m| m.uid.clone());
|
||||
let mut override_event = with_identity(updated, uid, Some(at.clone()));
|
||||
// An override describes one occurrence and must not carry the series'
|
||||
// recurrence rule; a resource with two RRULEs is two series.
|
||||
override_event.rrule = None;
|
||||
override_event.rdate.clear();
|
||||
override_event.exdate.clear();
|
||||
|
||||
let mut out = resource.clone();
|
||||
match out.events.iter().position(|e| same_recurrence_id(e, at)) {
|
||||
Some(index) => out.events[index] = override_event,
|
||||
None => out.events.push(override_event),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Truncates the series before `at` and starts a new one from it.
|
||||
fn split_series(
|
||||
resource: &VCalendar,
|
||||
master: &VEvent,
|
||||
updated: &VEvent,
|
||||
at: &CalendarDateTime,
|
||||
zones: Zones,
|
||||
) -> Result<EditOutcome, SeriesError> {
|
||||
let target = zones.instant(at);
|
||||
|
||||
// Splitting at the first occurrence leaves nothing behind, so it is an
|
||||
// edit of the whole series wearing a different hat. Producing an empty
|
||||
// truncated series plus a new one would be two resources where one belongs.
|
||||
if zones.instant(&master.dtstart) >= target {
|
||||
// The submitted event describes an *occurrence*, so it carries no
|
||||
// recurrence rule. Applied as-is it would quietly turn the series into
|
||||
// a single event -- the person asked to change every occurrence from
|
||||
// here on, not to stop it recurring.
|
||||
let mut whole = updated.clone();
|
||||
whole.rrule = master.rrule.clone();
|
||||
whole.rdate = master.rdate.clone();
|
||||
whole.exdate = master.exdate.clone();
|
||||
return Ok(EditOutcome::Replace(edit_whole_series(
|
||||
resource, master, &whole,
|
||||
)));
|
||||
}
|
||||
|
||||
let mut existing = resource.clone();
|
||||
truncate_before(&mut existing, target, zones)?;
|
||||
// Overrides from the split point onwards belong to the new series.
|
||||
existing
|
||||
.events
|
||||
.retain(|e| !e.is_override() || zones.instant(&recurrence_point(e)) < target);
|
||||
|
||||
let mut new_master = with_identity(updated, Some(uuid::Uuid::new_v4().to_string()), None);
|
||||
// The new series inherits the pattern, and whatever is left of the bound.
|
||||
//
|
||||
// A COUNT counted from the original start, so it has to be reduced by the
|
||||
// occurrences the first half kept; carrying it over unchanged would add
|
||||
// occurrences, and dropping it would turn "six times" into forever. An
|
||||
// UNTIL is an absolute instant and still means what it said, so it is
|
||||
// simply removed here and re-added below.
|
||||
new_master.rrule = match master.rrule.as_deref() {
|
||||
None => None,
|
||||
Some(rule) => Some(match count_of(rule) {
|
||||
Some(total) => {
|
||||
let consumed = occurrences_before(master, target, zones);
|
||||
set_count(rule, total.saturating_sub(consumed).max(1))?
|
||||
}
|
||||
None => strip_bounds(rule),
|
||||
}),
|
||||
};
|
||||
new_master.exdate = master
|
||||
.exdate
|
||||
.iter()
|
||||
.filter(|d| zones.instant(d) >= target)
|
||||
.cloned()
|
||||
.collect();
|
||||
new_master.rdate = master
|
||||
.rdate
|
||||
.iter()
|
||||
.filter(|d| zones.instant(d) >= target)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut new_series = VCalendar::with_events(vec![new_master]);
|
||||
new_series.timezones = resource.timezones.clone();
|
||||
|
||||
Ok(EditOutcome::Split {
|
||||
existing,
|
||||
new_series,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mechanics --
|
||||
|
||||
/// How many of a master's occurrences fall before an instant.
|
||||
///
|
||||
/// Counted by expansion rather than arithmetic: a rule with `BYDAY` or
|
||||
/// `BYMONTHDAY` does not have a closed form, and guessing is how v1's
|
||||
/// hand-rolled expander went wrong.
|
||||
fn occurrences_before(master: &VEvent, target: chrono::DateTime<chrono::Utc>, zones: Zones) -> u32 {
|
||||
let mut only_master = master.clone();
|
||||
only_master.recurrence_id = None;
|
||||
let calendar = VCalendar::with_events(vec![only_master]);
|
||||
|
||||
let window = crate::recurrence::Window::new(zones.instant(&master.dtstart), target);
|
||||
crate::recurrence::expand(&calendar, window, zones)
|
||||
.map(|occurrences| {
|
||||
u32::try_from(occurrences.iter().filter(|o| o.start_utc < target).count())
|
||||
.unwrap_or(u32::MAX)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Ends the master's rule at the last occurrence before `target`.
|
||||
fn truncate_before(
|
||||
calendar: &mut VCalendar,
|
||||
target: chrono::DateTime<chrono::Utc>,
|
||||
zones: Zones,
|
||||
) -> Result<(), SeriesError> {
|
||||
// UNTIL is inclusive, so it has to name an instant strictly before the
|
||||
// occurrence being cut. One second is the smallest step the format can
|
||||
// express.
|
||||
let until = target - TimeDelta::seconds(1);
|
||||
|
||||
for event in &mut calendar.events {
|
||||
if event.is_override() {
|
||||
continue;
|
||||
}
|
||||
if let Some(rule) = &event.rrule {
|
||||
event.rrule = Some(set_until(rule, until)?);
|
||||
}
|
||||
event.rdate.retain(|d| zones.instant(d) < target);
|
||||
event.exdate.retain(|d| zones.instant(d) < target);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies an edited event's content onto a fixed identity.
|
||||
///
|
||||
/// The `UID` and `RECURRENCE-ID` are the server's business, not the client's:
|
||||
/// taking them from the request would let a mistyped edit overwrite a different
|
||||
/// event, and letting the client invent them is how v1 ended up reconstructing
|
||||
/// identity by splitting `"{uid}-{timestamp}"` strings.
|
||||
fn with_identity(
|
||||
event: &VEvent,
|
||||
uid: Option<String>,
|
||||
recurrence_id: Option<CalendarDateTime>,
|
||||
) -> VEvent {
|
||||
let mut out = event.clone();
|
||||
if let Some(uid) = uid {
|
||||
out.uid = uid;
|
||||
}
|
||||
out.recurrence_id = recurrence_id;
|
||||
out.sequence = out.sequence.saturating_add(1);
|
||||
// Truncated to the second, because that is all iCalendar can express.
|
||||
// Holding a precision the wire format cannot carry means the value changes
|
||||
// the first time it is written out, and nothing downstream compares equal
|
||||
// to itself.
|
||||
let now = crate::model::now_to_second();
|
||||
out.last_modified = Some(now);
|
||||
out.dtstamp = now;
|
||||
out
|
||||
}
|
||||
|
||||
fn same_recurrence_id(event: &VEvent, at: &CalendarDateTime) -> bool {
|
||||
event.recurrence_id.as_ref() == Some(at)
|
||||
}
|
||||
|
||||
fn is_override_at(event: &VEvent, target: chrono::DateTime<chrono::Utc>, zones: Zones) -> bool {
|
||||
event
|
||||
.recurrence_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| zones.instant(id) == target)
|
||||
}
|
||||
|
||||
fn recurrence_point(event: &VEvent) -> CalendarDateTime {
|
||||
event
|
||||
.recurrence_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| event.dtstart.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SeriesError {
|
||||
/// The resource holds only overrides, so there is no series to edit.
|
||||
#[error("this resource has no series master")]
|
||||
NoMaster,
|
||||
|
||||
/// A scoped edit needs to know which occurrence it applies to.
|
||||
#[error("editing part of a series requires the occurrence's RECURRENCE-ID")]
|
||||
MissingRecurrenceId,
|
||||
|
||||
#[error(transparent)]
|
||||
Rule(#[from] RuleError),
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Changing the bounds of an `RRULE` without rewriting the rest of it.
|
||||
//!
|
||||
//! Done as a string operation on purpose. Parsing the rule into `rrule`'s type
|
||||
//! and rendering it back would work, but it normalises: part order changes,
|
||||
//! defaulted parts appear, and `WKST=SU` written by Exchange may or may not
|
||||
//! survive. Since the model stores the rule exactly as the producer wrote it,
|
||||
//! the edit should touch exactly the part being edited and leave every other
|
||||
//! byte alone.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Replaces a rule's bound with `UNTIL`.
|
||||
///
|
||||
/// `COUNT` and `UNTIL` are mutually exclusive (RFC 5545 §3.3.10), so any
|
||||
/// existing bound of either kind is removed first. A rule that said "ten times"
|
||||
/// cannot also say "until March".
|
||||
pub fn set_until(rrule: &str, until: DateTime<Utc>) -> Result<String, RuleError> {
|
||||
let mut parts = keep_unbounded_parts(rrule)?;
|
||||
parts.push(format!("UNTIL={}", until.format("%Y%m%dT%H%M%SZ")));
|
||||
Ok(parts.join(";"))
|
||||
}
|
||||
|
||||
/// Removes `COUNT` and `UNTIL`, leaving an unbounded rule.
|
||||
///
|
||||
/// Used when a series is split: the second half inherits the pattern but not
|
||||
/// the bound, because "ten occurrences" counted from the original start and
|
||||
/// means nothing measured from half way along.
|
||||
pub fn strip_bounds(rrule: &str) -> String {
|
||||
keep_unbounded_parts(rrule)
|
||||
.map(|parts| parts.join(";"))
|
||||
.unwrap_or_else(|_| rrule.to_owned())
|
||||
}
|
||||
|
||||
/// The `COUNT` a rule carries, if it is bounded that way.
|
||||
pub fn count_of(rrule: &str) -> Option<u32> {
|
||||
rrule
|
||||
.split(';')
|
||||
.filter_map(|part| part.trim().split_once('='))
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("COUNT"))
|
||||
.and_then(|(_, value)| value.trim().parse().ok())
|
||||
}
|
||||
|
||||
/// Replaces a rule's bound with `COUNT`.
|
||||
///
|
||||
/// Used when a counted series is split: "six times" was counted from the
|
||||
/// original start, so the second half carries however many are left rather
|
||||
/// than becoming unbounded. Dropping the bound would quietly turn a finite
|
||||
/// commitment into a permanent one.
|
||||
pub fn set_count(rrule: &str, count: u32) -> Result<String, RuleError> {
|
||||
let mut parts = keep_unbounded_parts(rrule)?;
|
||||
parts.push(format!("COUNT={count}"));
|
||||
Ok(parts.join(";"))
|
||||
}
|
||||
|
||||
fn keep_unbounded_parts(rrule: &str) -> Result<Vec<String>, RuleError> {
|
||||
let trimmed = rrule.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(RuleError::Empty);
|
||||
}
|
||||
|
||||
let parts: Vec<String> = trimmed
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.filter(|part| {
|
||||
let name = part.split('=').next().unwrap_or_default();
|
||||
!name.eq_ignore_ascii_case("UNTIL") && !name.eq_ignore_ascii_case("COUNT")
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
|
||||
if !parts.iter().any(|part| {
|
||||
part.split('=')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.eq_ignore_ascii_case("FREQ")
|
||||
}) {
|
||||
return Err(RuleError::NoFrequency(trimmed.to_owned()));
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RuleError {
|
||||
#[error("the recurrence rule is empty")]
|
||||
Empty,
|
||||
/// Every `RRULE` must have a `FREQ`; without one there is no pattern and
|
||||
/// bounding it would produce something meaningless.
|
||||
#[error("the recurrence rule {0:?} has no FREQ")]
|
||||
NoFrequency(String),
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
//! Editing and deleting parts of a series.
|
||||
//!
|
||||
//! The three scopes are the subtlest thing a calendar does, and v1 shipped them
|
||||
//! across 1,165 lines of duplicated handlers with no automated coverage at all.
|
||||
//! Each case here states what should end up in the stored `.ics`, then checks
|
||||
//! it by expanding the result — because the question that matters is not "what
|
||||
//! did we write" but "what does a calendar client see afterwards".
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use pretty_assertions::assert_eq;
|
||||
use runway_core::ical;
|
||||
use runway_core::model::*;
|
||||
use runway_core::recurrence::{Window, Zones, expand};
|
||||
use runway_core::series::{
|
||||
DeleteOutcome, EditOutcome, SeriesError, apply_delete, apply_edit, set_until, strip_bounds,
|
||||
};
|
||||
|
||||
fn denver() -> Tz {
|
||||
Tz::America__Denver
|
||||
}
|
||||
|
||||
fn zones() -> Zones {
|
||||
Zones::new(denver())
|
||||
}
|
||||
|
||||
fn at(day: u32, hour: u32) -> CalendarDateTime {
|
||||
CalendarDateTime::Zoned {
|
||||
local: chrono::NaiveDate::from_ymd_opt(2026, 1, day)
|
||||
.unwrap()
|
||||
.and_hms_opt(hour, 0, 0)
|
||||
.unwrap(),
|
||||
tzid: TzId::new("America/Denver").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn instant(text: &str) -> DateTime<Utc> {
|
||||
text.parse().unwrap()
|
||||
}
|
||||
|
||||
/// A weekly Monday 09:00 series starting 2026-01-05, six occurrences.
|
||||
fn weekly_series() -> VCalendar {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:standup@test\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART;TZID=America/Denver:20260105T090000\r\n\
|
||||
DTEND;TZID=America/Denver:20260105T093000\r\n\
|
||||
SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=6\r\n\
|
||||
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
ical::parse(ics).unwrap()
|
||||
}
|
||||
|
||||
fn one_off() -> VCalendar {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:dentist@test\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART;TZID=America/Denver:20260105T090000\r\n\
|
||||
DTEND;TZID=America/Denver:20260105T093000\r\n\
|
||||
SUMMARY:Dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
ical::parse(ics).unwrap()
|
||||
}
|
||||
|
||||
/// The edited form of an occurrence: moved to 14:00 and renamed.
|
||||
fn moved_to_afternoon(day: u32) -> VEvent {
|
||||
let mut event =
|
||||
VEvent::with_uid("ignored-by-the-server", at(day, 14)).titled("Standup (moved)");
|
||||
event.end = Some(EventEnd::DateTime { dtend: at(day, 15) });
|
||||
event
|
||||
}
|
||||
|
||||
/// Local start times an outcome produces over January, as a calendar would
|
||||
/// show them.
|
||||
fn shown(calendars: &[&VCalendar]) -> Vec<String> {
|
||||
let window = Window::new(
|
||||
instant("2026-01-01T00:00:00Z"),
|
||||
instant("2026-03-01T00:00:00Z"),
|
||||
);
|
||||
let mut out: Vec<(DateTime<Utc>, String)> = Vec::new();
|
||||
for calendar in calendars {
|
||||
for occurrence in expand(calendar, window, zones()).unwrap() {
|
||||
out.push((
|
||||
occurrence.start_utc,
|
||||
format!(
|
||||
"{} {}",
|
||||
occurrence.start.naive_local().format("%Y-%m-%d %H:%M"),
|
||||
occurrence.title().unwrap_or("(untitled)"),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|(instant, _)| *instant);
|
||||
out.into_iter().map(|(_, label)| label).collect()
|
||||
}
|
||||
|
||||
fn replaced(outcome: EditOutcome) -> VCalendar {
|
||||
match outcome {
|
||||
EditOutcome::Replace(calendar) => calendar,
|
||||
EditOutcome::Split { .. } => panic!("expected a single resource, got a split"),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- entire series --
|
||||
|
||||
#[test]
|
||||
fn editing_the_whole_series_edits_the_master_in_place() {
|
||||
let series = weekly_series();
|
||||
let mut renamed = series.master().unwrap().clone();
|
||||
renamed.summary = Some("Standup (renamed)".to_owned());
|
||||
|
||||
let result =
|
||||
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
|
||||
|
||||
assert_eq!(result.events.len(), 1, "one master, no override");
|
||||
assert_eq!(
|
||||
result.master().unwrap().rrule.as_deref(),
|
||||
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"),
|
||||
"the rule is untouched",
|
||||
);
|
||||
assert!(shown(&[&result]).iter().all(|s| s.contains("renamed")));
|
||||
assert_eq!(shown(&[&result]).len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_whole_series_keeps_its_identity() {
|
||||
let series = weekly_series();
|
||||
let mut renamed = series.master().unwrap().clone();
|
||||
renamed.uid = "a-uid-the-client-made-up".to_owned();
|
||||
|
||||
let result =
|
||||
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
|
||||
|
||||
assert_eq!(
|
||||
result.master().unwrap().uid,
|
||||
"standup@test",
|
||||
"the UID is the server's business; taking it from the request would let \
|
||||
a mistyped edit overwrite a different event",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_series_carries_its_overrides_with_it() {
|
||||
// The case that would otherwise lose data: an override names an occurrence
|
||||
// of the *old* pattern, so moving the master leaves it pointing at nothing.
|
||||
let mut series = weekly_series();
|
||||
let mut exception = moved_to_afternoon(12);
|
||||
exception.uid = "standup@test".to_owned();
|
||||
exception.recurrence_id = Some(at(12, 9));
|
||||
series.events.push(exception);
|
||||
|
||||
let mut moved = series.master().unwrap().clone();
|
||||
moved.dtstart = at(5, 11);
|
||||
moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) });
|
||||
|
||||
let result =
|
||||
replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap());
|
||||
|
||||
assert_eq!(result.overrides().count(), 1, "the override survived");
|
||||
assert_eq!(
|
||||
result.overrides().next().unwrap().recurrence_id,
|
||||
Some(at(12, 11)),
|
||||
"and its RECURRENCE-ID moved by the same two hours, so it still names \
|
||||
a real occurrence instead of being silently discarded",
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- this only --
|
||||
|
||||
#[test]
|
||||
fn editing_one_occurrence_writes_an_override_and_nothing_else() {
|
||||
let series = weekly_series();
|
||||
|
||||
let result = replaced(
|
||||
apply_edit(
|
||||
&series,
|
||||
EditScope::ThisOnly,
|
||||
Some(&at(12, 9)),
|
||||
&moved_to_afternoon(12),
|
||||
zones(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(result.events.len(), 2, "master plus one override");
|
||||
let exception = result.overrides().next().unwrap();
|
||||
assert_eq!(exception.uid, "standup@test", "one resource, one UID");
|
||||
assert_eq!(exception.recurrence_id, Some(at(12, 9)));
|
||||
assert_eq!(exception.dtstart, at(12, 14));
|
||||
assert!(
|
||||
exception.rrule.is_none(),
|
||||
"an override describes one occurrence; a second RRULE would make this \
|
||||
two series in one resource",
|
||||
);
|
||||
assert!(
|
||||
result.master().unwrap().exdate.is_empty(),
|
||||
"no EXDATE: an EXDATE says the occurrence does not happen, an override \
|
||||
says it happens differently, and writing both is contradictory",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edited_occurrence_appears_once_at_its_new_time() {
|
||||
let series = weekly_series();
|
||||
|
||||
let result = replaced(
|
||||
apply_edit(
|
||||
&series,
|
||||
EditScope::ThisOnly,
|
||||
Some(&at(12, 9)),
|
||||
&moved_to_afternoon(12),
|
||||
zones(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
shown(&[&result]),
|
||||
vec![
|
||||
"2026-01-05 09:00 Standup",
|
||||
"2026-01-12 14:00 Standup (moved)",
|
||||
"2026-01-19 09:00 Standup",
|
||||
"2026-01-26 09:00 Standup",
|
||||
"2026-02-02 09:00 Standup",
|
||||
"2026-02-09 09:00 Standup",
|
||||
],
|
||||
"still six occurrences, one of them moved -- not seven, and not five",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_same_occurrence_twice_replaces_its_override() {
|
||||
let series = weekly_series();
|
||||
let once = replaced(
|
||||
apply_edit(
|
||||
&series,
|
||||
EditScope::ThisOnly,
|
||||
Some(&at(12, 9)),
|
||||
&moved_to_afternoon(12),
|
||||
zones(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let mut again = moved_to_afternoon(12);
|
||||
again.dtstart = at(12, 16);
|
||||
again.end = Some(EventEnd::DateTime { dtend: at(12, 17) });
|
||||
let twice = replaced(
|
||||
apply_edit(
|
||||
&once,
|
||||
EditScope::ThisOnly,
|
||||
Some(&at(12, 9)),
|
||||
&again,
|
||||
zones(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
twice.overrides().count(),
|
||||
1,
|
||||
"a second edit of the same occurrence must replace the override, not \
|
||||
add a duplicate",
|
||||
);
|
||||
assert_eq!(twice.overrides().next().unwrap().dtstart, at(12, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_one_occurrence_without_saying_which_is_refused() {
|
||||
let series = weekly_series();
|
||||
|
||||
assert_eq!(
|
||||
apply_edit(
|
||||
&series,
|
||||
EditScope::ThisOnly,
|
||||
None,
|
||||
&moved_to_afternoon(12),
|
||||
zones()
|
||||
)
|
||||
.unwrap_err(),
|
||||
SeriesError::MissingRecurrenceId,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- this and future --
|
||||
|
||||
#[test]
|
||||
fn splitting_a_series_truncates_the_first_and_starts_a_second() {
|
||||
let series = weekly_series();
|
||||
|
||||
let outcome = apply_edit(
|
||||
&series,
|
||||
EditScope::ThisAndFuture,
|
||||
Some(&at(19, 9)),
|
||||
&moved_to_afternoon(19),
|
||||
zones(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let EditOutcome::Split {
|
||||
existing,
|
||||
new_series,
|
||||
} = outcome
|
||||
else {
|
||||
panic!("this-and-future is two resources, not one");
|
||||
};
|
||||
|
||||
let rule = existing.master().unwrap().rrule.clone().unwrap();
|
||||
assert!(
|
||||
rule.contains("UNTIL=20260119T155959Z"),
|
||||
"the original ends one second before the split point: {rule}",
|
||||
);
|
||||
assert!(
|
||||
!rule.contains("COUNT"),
|
||||
"COUNT and UNTIL are mutually exclusive: {rule}",
|
||||
);
|
||||
|
||||
assert_ne!(
|
||||
new_series.master().unwrap().uid,
|
||||
"standup@test",
|
||||
"a new series is a new resource with its own UID",
|
||||
);
|
||||
assert_eq!(
|
||||
new_series.master().unwrap().rrule.as_deref(),
|
||||
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=4"),
|
||||
"it inherits the pattern and what is left of the bound: two of the six \
|
||||
stayed with the original, so four remain. Carrying COUNT=6 over would \
|
||||
add occurrences; dropping it would turn \"six times\" into forever",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_series_shows_the_right_occurrences_on_both_sides() {
|
||||
let series = weekly_series();
|
||||
|
||||
let EditOutcome::Split {
|
||||
existing,
|
||||
new_series,
|
||||
} = apply_edit(
|
||||
&series,
|
||||
EditScope::ThisAndFuture,
|
||||
Some(&at(19, 9)),
|
||||
&moved_to_afternoon(19),
|
||||
zones(),
|
||||
)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected a split");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
shown(&[&existing]),
|
||||
vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"],
|
||||
"the old series stops before the split",
|
||||
);
|
||||
assert_eq!(
|
||||
shown(&[&new_series]),
|
||||
vec![
|
||||
"2026-01-19 14:00 Standup (moved)",
|
||||
"2026-01-26 14:00 Standup (moved)",
|
||||
"2026-02-02 14:00 Standup (moved)",
|
||||
"2026-02-09 14:00 Standup (moved)",
|
||||
],
|
||||
"and the new one takes over at the new time, for the four that were \
|
||||
left -- six in total across both halves, as originally asked for",
|
||||
);
|
||||
assert!(
|
||||
!shown(&[&existing, &new_series]).contains(&"2026-01-19 09:00 Standup".to_owned()),
|
||||
"nothing may appear at the old time on the split day",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_at_the_first_occurrence_is_just_editing_the_series() {
|
||||
let series = weekly_series();
|
||||
|
||||
let outcome = apply_edit(
|
||||
&series,
|
||||
EditScope::ThisAndFuture,
|
||||
Some(&at(5, 9)),
|
||||
&moved_to_afternoon(5),
|
||||
zones(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = replaced(outcome);
|
||||
assert_eq!(
|
||||
result.master().unwrap().uid,
|
||||
"standup@test",
|
||||
"splitting at the very start would leave an empty truncated series and \
|
||||
a new one, which is two resources where one belongs",
|
||||
);
|
||||
assert_eq!(
|
||||
result.master().unwrap().rrule.as_deref(),
|
||||
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"),
|
||||
"and the rule survives: the person asked to change every occurrence \
|
||||
from the first one onwards, not to stop the series recurring",
|
||||
);
|
||||
assert_eq!(shown(&[&result]).len(), 6);
|
||||
assert!(shown(&[&result]).iter().all(|s| s.contains("14:00")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_the_rule_on_the_whole_series_makes_it_a_single_event() {
|
||||
// Under EntireSeries the submitted event *is* the master, so dropping the
|
||||
// rule is an instruction, not an omission. The split path is the opposite
|
||||
// case and carries the rule forward; see the test above.
|
||||
let series = weekly_series();
|
||||
let mut once_only = series.master().unwrap().clone();
|
||||
once_only.rrule = None;
|
||||
|
||||
let result =
|
||||
replaced(apply_edit(&series, EditScope::EntireSeries, None, &once_only, zones()).unwrap());
|
||||
|
||||
assert_eq!(shown(&[&result]), vec!["2026-01-05 09:00 Standup"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_hands_later_overrides_to_the_new_series() {
|
||||
let mut series = weekly_series();
|
||||
for day in [12, 26] {
|
||||
let mut exception = moved_to_afternoon(day);
|
||||
exception.uid = "standup@test".to_owned();
|
||||
exception.recurrence_id = Some(at(day, 9));
|
||||
series.events.push(exception);
|
||||
}
|
||||
|
||||
let EditOutcome::Split { existing, .. } = apply_edit(
|
||||
&series,
|
||||
EditScope::ThisAndFuture,
|
||||
Some(&at(19, 9)),
|
||||
&moved_to_afternoon(19),
|
||||
zones(),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!("expected a split");
|
||||
};
|
||||
|
||||
let kept: Vec<_> = existing.overrides().collect();
|
||||
assert_eq!(kept.len(), 1, "only the override before the split stays");
|
||||
assert_eq!(kept[0].recurrence_id, Some(at(12, 9)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ deleting --
|
||||
|
||||
#[test]
|
||||
fn deleting_one_occurrence_adds_an_exdate() {
|
||||
let series = weekly_series();
|
||||
|
||||
let DeleteOutcome::Replace(result) =
|
||||
apply_delete(&series, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap()
|
||||
else {
|
||||
panic!("deleting one occurrence keeps the resource");
|
||||
};
|
||||
|
||||
assert_eq!(result.master().unwrap().exdate, vec![at(12, 9)]);
|
||||
assert_eq!(
|
||||
shown(&[&result]),
|
||||
vec![
|
||||
"2026-01-05 09:00 Standup",
|
||||
"2026-01-19 09:00 Standup",
|
||||
"2026-01-26 09:00 Standup",
|
||||
"2026-02-02 09:00 Standup",
|
||||
"2026-02-09 09:00 Standup",
|
||||
],
|
||||
"the 12th is gone and the rest are untouched",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_occurrence_that_was_edited_removes_its_override_too() {
|
||||
let series = weekly_series();
|
||||
let edited = replaced(
|
||||
apply_edit(
|
||||
&series,
|
||||
EditScope::ThisOnly,
|
||||
Some(&at(12, 9)),
|
||||
&moved_to_afternoon(12),
|
||||
zones(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let DeleteOutcome::Replace(result) =
|
||||
apply_delete(&edited, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap()
|
||||
else {
|
||||
panic!("expected the resource to survive");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
result.overrides().count(),
|
||||
0,
|
||||
"an EXDATE without removing the override would leave the moved copy \
|
||||
showing on a day the person just deleted",
|
||||
);
|
||||
assert!(!shown(&[&result]).iter().any(|s| s.contains("2026-01-12")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_this_and_future_truncates_the_series() {
|
||||
let series = weekly_series();
|
||||
|
||||
let DeleteOutcome::Replace(result) =
|
||||
apply_delete(&series, EditScope::ThisAndFuture, Some(&at(19, 9)), zones()).unwrap()
|
||||
else {
|
||||
panic!("expected the resource to survive");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
shown(&[&result]),
|
||||
vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_entire_series_removes_the_resource() {
|
||||
let series = weekly_series();
|
||||
|
||||
assert_eq!(
|
||||
apply_delete(&series, EditScope::EntireSeries, None, zones()).unwrap(),
|
||||
DeleteOutcome::Remove,
|
||||
"there is nothing left to write back",
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- one-off events
|
||||
|
||||
#[test]
|
||||
fn every_scope_means_the_same_thing_for_a_one_off_event() {
|
||||
let event = one_off();
|
||||
let mut renamed = event.master().unwrap().clone();
|
||||
renamed.summary = Some("Dentist (rescheduled)".to_owned());
|
||||
|
||||
for scope in [
|
||||
EditScope::ThisOnly,
|
||||
EditScope::ThisAndFuture,
|
||||
EditScope::EntireSeries,
|
||||
] {
|
||||
let result =
|
||||
replaced(apply_edit(&event, scope, Some(&at(5, 9)), &renamed, zones()).unwrap());
|
||||
assert_eq!(result.events.len(), 1, "{scope:?}");
|
||||
assert_eq!(result.master().unwrap().uid, "dentist@test", "{scope:?}");
|
||||
assert_eq!(shown(&[&result]).len(), 1, "{scope:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_one_off_event_removes_it_whatever_the_scope() {
|
||||
let event = one_off();
|
||||
|
||||
for scope in [
|
||||
EditScope::ThisOnly,
|
||||
EditScope::ThisAndFuture,
|
||||
EditScope::EntireSeries,
|
||||
] {
|
||||
assert_eq!(
|
||||
apply_delete(&event, scope, Some(&at(5, 9)), zones()).unwrap(),
|
||||
DeleteOutcome::Remove,
|
||||
"{scope:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rule edits --
|
||||
|
||||
#[test]
|
||||
fn setting_until_replaces_any_existing_bound() {
|
||||
let until = instant("2026-01-19T15:59:59Z");
|
||||
|
||||
assert_eq!(
|
||||
set_until("FREQ=WEEKLY;BYDAY=MO;COUNT=6", until).unwrap(),
|
||||
"FREQ=WEEKLY;BYDAY=MO;UNTIL=20260119T155959Z",
|
||||
);
|
||||
assert_eq!(
|
||||
set_until("FREQ=WEEKLY;UNTIL=20270101T000000Z", until).unwrap(),
|
||||
"FREQ=WEEKLY;UNTIL=20260119T155959Z",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_until_leaves_every_other_part_exactly_as_written() {
|
||||
// Exchange writes WKST=SU and orders its parts its own way. Parsing the
|
||||
// rule and rendering it back would normalise both; this touches only the
|
||||
// bound.
|
||||
let rule = "FREQ=WEEKLY;INTERVAL=1;BYDAY=WE;WKST=SU";
|
||||
|
||||
let bounded = set_until(rule, instant("2026-01-19T15:59:59Z")).unwrap();
|
||||
|
||||
assert!(bounded.starts_with(rule), "{bounded}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unbounded_series_stays_unbounded_when_split() {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:forever@test\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART;TZID=America/Denver:20260105T090000\r\n\
|
||||
SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
|
||||
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
let series = ical::parse(ics).unwrap();
|
||||
|
||||
let EditOutcome::Split { new_series, .. } = apply_edit(
|
||||
&series,
|
||||
EditScope::ThisAndFuture,
|
||||
Some(&at(19, 9)),
|
||||
&moved_to_afternoon(19),
|
||||
zones(),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!("expected a split");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
new_series.master().unwrap().rrule.as_deref(),
|
||||
Some("FREQ=WEEKLY;BYDAY=MO"),
|
||||
"there was no bound to divide up",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_counted_series_keeps_its_total_across_a_split() {
|
||||
// The property that matters: however the series is cut, the number of
|
||||
// occurrences the person asked for is the number they end up with.
|
||||
let series = weekly_series();
|
||||
|
||||
for split_day in [12, 19, 26] {
|
||||
let EditOutcome::Split {
|
||||
existing,
|
||||
new_series,
|
||||
} = apply_edit(
|
||||
&series,
|
||||
EditScope::ThisAndFuture,
|
||||
Some(&at(split_day, 9)),
|
||||
&moved_to_afternoon(split_day),
|
||||
zones(),
|
||||
)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected a split at day {split_day}");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
shown(&[&existing, &new_series]).len(),
|
||||
6,
|
||||
"splitting at the {split_day}th changed the total",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripping_bounds_leaves_the_pattern() {
|
||||
assert_eq!(
|
||||
strip_bounds("FREQ=MONTHLY;BYDAY=3TU;COUNT=12"),
|
||||
"FREQ=MONTHLY;BYDAY=3TU",
|
||||
);
|
||||
assert_eq!(strip_bounds("FREQ=DAILY"), "FREQ=DAILY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_with_no_frequency_is_refused() {
|
||||
assert!(set_until("COUNT=5", instant("2026-01-19T15:59:59Z")).is_err());
|
||||
assert!(set_until("", instant("2026-01-19T15:59:59Z")).is_err());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ written output --
|
||||
|
||||
#[test]
|
||||
fn every_outcome_is_still_valid_icalendar() {
|
||||
// The results are written to a real server, so they have to survive the
|
||||
// round trip the whole of M3 exists to guarantee.
|
||||
let series = weekly_series();
|
||||
let mut outcomes = vec![replaced(
|
||||
apply_edit(
|
||||
&series,
|
||||
EditScope::ThisOnly,
|
||||
Some(&at(12, 9)),
|
||||
&moved_to_afternoon(12),
|
||||
zones(),
|
||||
)
|
||||
.unwrap(),
|
||||
)];
|
||||
if let EditOutcome::Split {
|
||||
existing,
|
||||
new_series,
|
||||
} = apply_edit(
|
||||
&series,
|
||||
EditScope::ThisAndFuture,
|
||||
Some(&at(19, 9)),
|
||||
&moved_to_afternoon(19),
|
||||
zones(),
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
outcomes.push(existing);
|
||||
outcomes.push(new_series);
|
||||
}
|
||||
|
||||
for calendar in &outcomes {
|
||||
let written = ical::write(calendar);
|
||||
let reparsed = ical::parse(&written).expect("outcomes must be readable");
|
||||
assert_eq!(&reparsed, calendar, "changed meaning on the way out");
|
||||
for line in written.split("\r\n") {
|
||||
assert!(line.len() <= 75, "unfolded line: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_bumps_the_sequence_number() {
|
||||
let series = weekly_series();
|
||||
let before = series.master().unwrap().sequence;
|
||||
let mut renamed = series.master().unwrap().clone();
|
||||
renamed.summary = Some("Renamed".to_owned());
|
||||
|
||||
let result =
|
||||
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
|
||||
|
||||
assert_eq!(
|
||||
result.master().unwrap().sequence,
|
||||
before + 1,
|
||||
"SEQUENCE is how other clients know a revision happened; leaving it \
|
||||
still makes an edited invitation look unchanged",
|
||||
);
|
||||
assert!(result.master().unwrap().last_modified.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resource_with_only_overrides_cannot_be_edited() {
|
||||
let mut orphan = weekly_series();
|
||||
orphan.events.retain(VEvent::is_override);
|
||||
orphan.events.push({
|
||||
let mut exception = moved_to_afternoon(12);
|
||||
exception.recurrence_id = Some(at(12, 9));
|
||||
exception
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
apply_edit(
|
||||
&orphan,
|
||||
EditScope::EntireSeries,
|
||||
None,
|
||||
&moved_to_afternoon(12),
|
||||
zones()
|
||||
)
|
||||
.unwrap_err(),
|
||||
SeriesError::NoMaster,
|
||||
"there is no series to apply a scope to, and guessing would be worse \
|
||||
than saying so",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_across_a_dst_boundary_keeps_the_wall_clock_time() {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:dst@test\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART;TZID=America/Denver:20260302T090000\r\n\
|
||||
DTEND;TZID=America/Denver:20260302T093000\r\n\
|
||||
SUMMARY:Weekly\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\r\n\
|
||||
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
let series = ical::parse(ics).unwrap();
|
||||
|
||||
// Denver springs forward on 2026-03-08, so the split point is on the far
|
||||
// side of the change and UNTIL has to be computed from the right offset.
|
||||
let split_at = CalendarDateTime::Zoned {
|
||||
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 16)
|
||||
.unwrap()
|
||||
.and_hms_opt(9, 0, 0)
|
||||
.unwrap(),
|
||||
tzid: TzId::new("America/Denver").unwrap(),
|
||||
};
|
||||
|
||||
let DeleteOutcome::Replace(result) =
|
||||
apply_delete(&series, EditScope::ThisAndFuture, Some(&split_at), zones()).unwrap()
|
||||
else {
|
||||
panic!("expected the resource to survive");
|
||||
};
|
||||
|
||||
let rule = result.master().unwrap().rrule.clone().unwrap();
|
||||
assert!(
|
||||
rule.contains("UNTIL=20260316T145959Z"),
|
||||
"09:00 Denver on 16 March is 15:00 UTC because daylight time has begun; \
|
||||
computing it from the winter offset would delete a week too many or \
|
||||
too few: {rule}",
|
||||
);
|
||||
|
||||
let window = Window::new(
|
||||
instant("2026-03-01T00:00:00Z"),
|
||||
instant("2026-04-01T00:00:00Z"),
|
||||
);
|
||||
let remaining = expand(&result, window, zones()).unwrap();
|
||||
assert_eq!(
|
||||
remaining.len(),
|
||||
2,
|
||||
"2 and 9 March survive, 16 and 23 do not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_round_trip_through_json() {
|
||||
for scope in [
|
||||
EditScope::ThisOnly,
|
||||
EditScope::ThisAndFuture,
|
||||
EditScope::EntireSeries,
|
||||
] {
|
||||
let encoded = serde_json::to_string(&scope).unwrap();
|
||||
assert_eq!(serde_json::from_str::<EditScope>(&encoded).unwrap(), scope);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EditScope::ThisAndFuture).unwrap(),
|
||||
"\"this_and_future\"",
|
||||
"the wire form is stable; v1 dispatched on 53 hand-written string \
|
||||
literals and a typo was a runtime fallthrough",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shifted_series_keeps_its_duration() {
|
||||
let series = weekly_series();
|
||||
let mut moved = series.master().unwrap().clone();
|
||||
moved.dtstart = at(5, 11);
|
||||
moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) });
|
||||
|
||||
let result =
|
||||
replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap());
|
||||
|
||||
assert_eq!(result.master().unwrap().duration(), TimeDelta::hours(1));
|
||||
}
|
||||
Reference in New Issue
Block a user