Files
runway/crates/runway-core/src/series/rule.rs
T
connor c4e8ede28c 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.
2026-08-26 17:07:38 -04:00

93 lines
3.3 KiB
Rust

//! 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),
}