Add the RFC 5545 domain model
VEvent and friends, transcribed from the previous calendar-models crate and tightened so that the states which caused its timezone and recurrence bugs cannot be represented. The substantive changes from v1: CalendarDateTime is an enum over the four forms RFC 5545 actually admits (date, floating, UTC, zoned) instead of a NaiveDateTime plus a loose Option<String> zone plus an all_day flag that could all disagree. A zoned value carries an IANA identifier, never a UTC offset -- an offset cannot tell standard time from daylight time, which is why recurring events drifted an hour across DST. EventEnd is an enum, because DTEND and DURATION are mutually exclusive. Priority validates 0-9 on construction and on deserialisation. EditScope replaces dispatch on strings like "this_and_future", which appeared 53 times and turned typos into silent fallthrough. CalendarObject models a CalDAV resource as it really is: one UID, one master, N RECURRENCE-ID overrides. v1 flattened this to a bare event list, which made overrides look like duplicates and motivated ~500 lines of title-matching heuristics that silently discarded events. Dropped VJournal, VFreeBusy, VTimeZone, TodoStatus, FreeBusyType and Period: defined but never used. 28 tests cover serde round-trips, the exact JSON shape (the model is the wire format, so changing it should be deliberate), duration fallbacks, validation boundaries and master/override separation. uuid needs an explicit entropy source on wasm; without it the frontend cannot compile the shared model. Verified that the default feature set pulls in neither icalendar, rrule, chrono-tz, quick-xml nor reqwest.
This commit is contained in:
@@ -23,6 +23,12 @@ chrono-tz = { workspace = true, optional = true }
|
|||||||
icalendar = { workspace = true, optional = true }
|
icalendar = { workspace = true, optional = true }
|
||||||
rrule = { workspace = true, optional = true }
|
rrule = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
# On wasm there is no OS entropy source, so uuid needs to be pointed at the
|
||||||
|
# browser's crypto API explicitly. Without this the frontend cannot compile the
|
||||||
|
# shared model at all -- worth catching in CI, not at deploy time.
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
uuid = { workspace = true, features = ["js"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
//! Pure calendar domain logic for Runway.
|
//! Pure calendar domain logic for Runway.
|
||||||
//!
|
//!
|
||||||
//! This crate holds the RFC 5545 model and, behind feature flags, the iCalendar
|
//! This crate holds the RFC 5545 model and, behind feature flags, the iCalendar
|
||||||
//! round-trip and recurrence expansion. It performs no I/O and has no knowledge
|
//! round-trip and recurrence expansion. It performs no I/O and knows nothing of
|
||||||
//! of HTTP, CalDAV or the database, which keeps it fast to test and — with only
|
//! HTTP, CalDAV or the database, which keeps it fast to test and — with only the
|
||||||
//! the default `model` feature — cheap enough to compile into the WASM bundle.
|
//! default `model` feature — cheap enough to compile into the WASM bundle.
|
||||||
|
//!
|
||||||
|
//! # Features
|
||||||
|
//!
|
||||||
|
//! - `model` (default): the types below. No heavy dependencies.
|
||||||
|
//! - `ical`: parsing and serialising `VCALENDAR` data.
|
||||||
|
//! - `recurrence`: expanding `RRULE`s into concrete occurrences.
|
||||||
|
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
|
||||||
|
pub use model::*;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pub struct Placeholder;
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! `VALARM` — RFC 5545 §3.6.6.
|
||||||
|
//!
|
||||||
|
//! Authoring alarms correctly matters more than displaying them: the alarms this
|
||||||
|
//! client writes are consumed by whatever else reads the calendar, phones
|
||||||
|
//! included. Only `DISPLAY` is surfaced in the UI, but the model round-trips
|
||||||
|
//! every action so that alarms authored elsewhere survive an edit here.
|
||||||
|
|
||||||
|
use super::datetime::IcalDuration;
|
||||||
|
use super::person::Attendee;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct VAlarm {
|
||||||
|
pub action: AlarmAction,
|
||||||
|
pub trigger: AlarmTrigger,
|
||||||
|
/// `DURATION` — the gap between repeats. Meaningless without `repeat`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<IcalDuration>,
|
||||||
|
/// `REPEAT` — how many additional times to fire.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repeat: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub summary: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub attendees: Vec<Attendee>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VAlarm {
|
||||||
|
/// A display alarm firing a given duration before the event starts.
|
||||||
|
///
|
||||||
|
/// Pass a negative duration for "before"; that is the sign convention
|
||||||
|
/// RFC 5545 uses and getting it backwards is a classic source of alarms
|
||||||
|
/// that fire after the thing they were meant to announce.
|
||||||
|
pub fn display_before(before_start: IcalDuration, description: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
action: AlarmAction::Display,
|
||||||
|
trigger: AlarmTrigger::Relative {
|
||||||
|
offset: before_start,
|
||||||
|
related: TriggerRelation::Start,
|
||||||
|
},
|
||||||
|
duration: None,
|
||||||
|
repeat: None,
|
||||||
|
description: Some(description.into()),
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ACTION` — RFC 5545 §3.8.6.1.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum AlarmAction {
|
||||||
|
Display,
|
||||||
|
Audio,
|
||||||
|
Email,
|
||||||
|
/// Deprecated by RFC 5545 but still emitted by some servers.
|
||||||
|
Procedure,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TRIGGER` — RFC 5545 §3.8.6.3.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum AlarmTrigger {
|
||||||
|
/// An offset from the event's start or end.
|
||||||
|
Relative {
|
||||||
|
offset: IcalDuration,
|
||||||
|
related: TriggerRelation,
|
||||||
|
},
|
||||||
|
/// An absolute instant. Always UTC per the RFC.
|
||||||
|
Absolute { at: DateTime<Utc> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RELATED` — which end of the event a relative trigger hangs off.
|
||||||
|
///
|
||||||
|
/// The previous iteration ignored this parameter entirely and assumed `START`,
|
||||||
|
/// so alarms authored elsewhere as "10 minutes before the end" moved when
|
||||||
|
/// round-tripped.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum TriggerRelation {
|
||||||
|
#[default]
|
||||||
|
Start,
|
||||||
|
End,
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! RFC 5545 date and date-time values.
|
||||||
|
//!
|
||||||
|
//! The previous iteration modelled a start time as a `NaiveDateTime` plus a
|
||||||
|
//! separate `Option<String>` time zone, plus an `all_day: bool` flag. Those
|
||||||
|
//! three fields could disagree with one another, and reconciling them was the
|
||||||
|
//! source of most of its timezone bugs — including a wire format that sent a
|
||||||
|
//! raw UTC *offset* in place of a zone, so recurring events drifted an hour
|
||||||
|
//! across a DST boundary.
|
||||||
|
//!
|
||||||
|
//! RFC 5545 §3.3.4-5 admits exactly four shapes for such a value, so this is an
|
||||||
|
//! enum. A value carries its own interpretation and the illegal combinations
|
||||||
|
//! cannot be constructed.
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeDelta, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// An IANA time zone identifier, such as `America/Denver`.
|
||||||
|
///
|
||||||
|
/// Held as a string so that the default (frontend) build of this crate need not
|
||||||
|
/// embed the time zone database. Resolution to a real [`chrono_tz::Tz`] — and
|
||||||
|
/// therefore authoritative validation — lives behind the `tz` feature and
|
||||||
|
/// happens on the server, which is where a bad zone must be rejected.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct TzId(String);
|
||||||
|
|
||||||
|
impl TzId {
|
||||||
|
/// Wraps an identifier. Rejects only the empty string; see the type docs for
|
||||||
|
/// why full validation is deferred to [`TzId::resolve`].
|
||||||
|
pub fn new(id: impl Into<String>) -> Result<Self, InvalidTzId> {
|
||||||
|
let id = id.into();
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
return Err(InvalidTzId(id));
|
||||||
|
}
|
||||||
|
Ok(Self(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTC, spelled as a zone.
|
||||||
|
pub fn utc() -> Self {
|
||||||
|
Self("UTC".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TzId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A time zone identifier that is empty or not known to the time zone database.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("not a known IANA time zone: {0:?}")]
|
||||||
|
pub struct InvalidTzId(pub String);
|
||||||
|
|
||||||
|
/// A date-time as iCalendar represents it.
|
||||||
|
///
|
||||||
|
/// The four variants correspond one-to-one with the forms a `DTSTART`,
|
||||||
|
/// `DTEND`, `RECURRENCE-ID` or `EXDATE` property may take.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum CalendarDateTime {
|
||||||
|
/// `VALUE=DATE` — an all-day value. No time of day, no zone.
|
||||||
|
/// Serialised as `20231225`.
|
||||||
|
Date { date: NaiveDate },
|
||||||
|
|
||||||
|
/// A floating local time: `20231225T090000`, with no zone at all.
|
||||||
|
/// Means "this wall-clock time, wherever the reader happens to be".
|
||||||
|
Floating { local: NaiveDateTime },
|
||||||
|
|
||||||
|
/// An absolute time in UTC: `20231225T090000Z`.
|
||||||
|
Utc { utc: DateTime<Utc> },
|
||||||
|
|
||||||
|
/// A wall-clock time in a named zone: `TZID=America/Denver:20231225T090000`.
|
||||||
|
///
|
||||||
|
/// The local time is stored as written. Deriving the instant requires the
|
||||||
|
/// zone database and so is only available with the `tz` feature — which is
|
||||||
|
/// deliberate, because that conversion is exactly where DST correctness is
|
||||||
|
/// won or lost.
|
||||||
|
Zoned { local: NaiveDateTime, tzid: TzId },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarDateTime {
|
||||||
|
/// True when this is a date-only (all-day) value.
|
||||||
|
///
|
||||||
|
/// Replaces the previous iteration's separate `all_day: bool`, which could
|
||||||
|
/// contradict the datetime it described.
|
||||||
|
pub fn is_date_only(&self) -> bool {
|
||||||
|
matches!(self, Self::Date { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calendar date this value falls on, in its own frame of reference.
|
||||||
|
pub fn date(&self) -> NaiveDate {
|
||||||
|
match self {
|
||||||
|
Self::Date { date } => *date,
|
||||||
|
Self::Floating { local } | Self::Zoned { local, .. } => local.date(),
|
||||||
|
Self::Utc { utc } => utc.date_naive(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wall-clock time in this value's own frame of reference.
|
||||||
|
///
|
||||||
|
/// For [`Self::Date`] this is midnight. Note that comparing these across
|
||||||
|
/// different variants is meaningless; use `to_utc` when you need an instant.
|
||||||
|
pub fn naive_local(&self) -> NaiveDateTime {
|
||||||
|
match self {
|
||||||
|
Self::Date { date } => date.and_time(chrono::NaiveTime::MIN),
|
||||||
|
Self::Floating { local } | Self::Zoned { local, .. } => *local,
|
||||||
|
Self::Utc { utc } => utc.naive_utc(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zone this value names, if it names one.
|
||||||
|
pub fn tzid(&self) -> Option<&TzId> {
|
||||||
|
match self {
|
||||||
|
Self::Zoned { tzid, .. } => Some(tzid),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shifts the value by a duration, preserving its variant and zone.
|
||||||
|
///
|
||||||
|
/// For zoned values this is wall-clock arithmetic, matching how a calendar
|
||||||
|
/// user expects "move this 1 hour later" to behave across a DST boundary.
|
||||||
|
pub fn shifted(&self, by: TimeDelta) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Date { date } => Self::Date { date: *date + by },
|
||||||
|
Self::Floating { local } => Self::Floating { local: *local + by },
|
||||||
|
Self::Utc { utc } => Self::Utc { utc: *utc + by },
|
||||||
|
Self::Zoned { local, tzid } => Self::Zoned {
|
||||||
|
local: *local + by,
|
||||||
|
tzid: tzid.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an event ends. RFC 5545 §3.6.1 permits `DTEND` or `DURATION`, never both.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum EventEnd {
|
||||||
|
/// An explicit end instant.
|
||||||
|
DateTime { dtend: CalendarDateTime },
|
||||||
|
/// A length, relative to the start.
|
||||||
|
Duration { duration: IcalDuration },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed duration, serialised as whole seconds.
|
||||||
|
///
|
||||||
|
/// `chrono::TimeDelta` has no stable serde representation, and iCalendar's own
|
||||||
|
/// `-PT15M` syntax belongs in the iCalendar layer rather than in the JSON API,
|
||||||
|
/// so the wire format is a plain integer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct IcalDuration(#[serde(with = "seconds")] pub TimeDelta);
|
||||||
|
|
||||||
|
impl IcalDuration {
|
||||||
|
pub fn minutes(n: i64) -> Option<Self> {
|
||||||
|
TimeDelta::try_minutes(n).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hours(n: i64) -> Option<Self> {
|
||||||
|
TimeDelta::try_hours(n).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_time_delta(self) -> TimeDelta {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TimeDelta> for IcalDuration {
|
||||||
|
fn from(d: TimeDelta) -> Self {
|
||||||
|
Self(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod seconds {
|
||||||
|
use chrono::TimeDelta;
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(d: &TimeDelta, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_i64(d.num_seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<TimeDelta, D::Error> {
|
||||||
|
let secs = i64::deserialize(d)?;
|
||||||
|
TimeDelta::try_seconds(secs)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom(format!("duration out of range: {secs}s")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! `VEVENT` — RFC 5545 §3.6.1.
|
||||||
|
|
||||||
|
use super::alarm::VAlarm;
|
||||||
|
use super::datetime::{CalendarDateTime, EventEnd, IcalDuration};
|
||||||
|
use super::person::{Attendee, CalendarUser};
|
||||||
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A calendar event.
|
||||||
|
///
|
||||||
|
/// This type is both the domain model and the wire format. The previous
|
||||||
|
/// iteration maintained four parallel representations of an event — this struct,
|
||||||
|
/// a deprecated flattened copy, four stringly-typed request structs, and a form
|
||||||
|
/// model with its own duplicate enums — and lost data at every conversion
|
||||||
|
/// between them. There is deliberately only one here.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct VEvent {
|
||||||
|
// ---- Required ----
|
||||||
|
/// `UID`. Globally unique and stable across edits; the identity of a series.
|
||||||
|
pub uid: String,
|
||||||
|
/// `DTSTAMP`. When this representation was created. Always UTC.
|
||||||
|
pub dtstamp: DateTime<Utc>,
|
||||||
|
/// `DTSTART`. Carries its own zone; see [`CalendarDateTime`].
|
||||||
|
pub dtstart: CalendarDateTime,
|
||||||
|
|
||||||
|
// ---- Timing ----
|
||||||
|
/// `DTEND` or `DURATION`, never both. `None` means a zero-length event, or
|
||||||
|
/// for a date-only start, a single day.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub end: Option<EventEnd>,
|
||||||
|
|
||||||
|
// ---- Description ----
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub summary: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub location: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub geo: Option<GeoPosition>,
|
||||||
|
|
||||||
|
// ---- Classification ----
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status: Option<EventStatus>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub class: Option<EventClass>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transparency: Option<Transparency>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub priority: Option<Priority>,
|
||||||
|
|
||||||
|
// ---- People ----
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub organizer: Option<CalendarUser>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub attendees: Vec<Attendee>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub contact: Option<String>,
|
||||||
|
|
||||||
|
// ---- Grouping ----
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub categories: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub resources: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub comment: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub related_to: Option<String>,
|
||||||
|
|
||||||
|
// ---- Recurrence ----
|
||||||
|
/// `RRULE`, verbatim. Parsing and expansion belong to the `recurrence`
|
||||||
|
/// feature; the model stores what the server sent.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rrule: Option<String>,
|
||||||
|
/// `RDATE` — extra occurrences.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub rdate: Vec<CalendarDateTime>,
|
||||||
|
/// `EXDATE` — occurrences removed from the series.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub exdate: Vec<CalendarDateTime>,
|
||||||
|
/// `RECURRENCE-ID`. Present only on an override: this event replaces the
|
||||||
|
/// occurrence of `uid` that would otherwise fall at this time.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub recurrence_id: Option<CalendarDateTime>,
|
||||||
|
|
||||||
|
// ---- Bookkeeping ----
|
||||||
|
/// `SEQUENCE` — bumped on each significant revision.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sequence: u32,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_modified: Option<DateTime<Utc>>,
|
||||||
|
|
||||||
|
// ---- Sub-components ----
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub alarms: Vec<VAlarm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VEvent {
|
||||||
|
/// A new event with a freshly generated UID.
|
||||||
|
pub fn new(dtstart: CalendarDateTime) -> Self {
|
||||||
|
Self::with_uid(uuid::Uuid::new_v4().to_string(), dtstart)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new event with a caller-supplied UID.
|
||||||
|
pub fn with_uid(uid: impl Into<String>, dtstart: CalendarDateTime) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
uid: uid.into(),
|
||||||
|
dtstamp: now,
|
||||||
|
dtstart,
|
||||||
|
end: None,
|
||||||
|
summary: None,
|
||||||
|
description: None,
|
||||||
|
location: None,
|
||||||
|
url: None,
|
||||||
|
geo: None,
|
||||||
|
status: None,
|
||||||
|
class: None,
|
||||||
|
transparency: None,
|
||||||
|
priority: None,
|
||||||
|
organizer: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
contact: None,
|
||||||
|
categories: Vec::new(),
|
||||||
|
resources: Vec::new(),
|
||||||
|
comment: None,
|
||||||
|
related_to: None,
|
||||||
|
rrule: None,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
exdate: Vec::new(),
|
||||||
|
recurrence_id: None,
|
||||||
|
sequence: 0,
|
||||||
|
created: Some(now),
|
||||||
|
last_modified: Some(now),
|
||||||
|
alarms: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title to display. Empty summaries are treated as absent.
|
||||||
|
pub fn title(&self) -> Option<&str> {
|
||||||
|
self.summary.as_deref().filter(|s| !s.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this event occupies whole days rather than a span of time.
|
||||||
|
pub fn is_all_day(&self) -> bool {
|
||||||
|
self.dtstart.is_date_only()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this event defines a recurring series.
|
||||||
|
pub fn is_recurring(&self) -> bool {
|
||||||
|
self.rrule.is_some() || !self.rdate.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this event overrides a single occurrence of a series.
|
||||||
|
pub fn is_override(&self) -> bool {
|
||||||
|
self.recurrence_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the event lasts.
|
||||||
|
///
|
||||||
|
/// Falls back per RFC 5545 §3.6.1: a date-only event with no end lasts one
|
||||||
|
/// day, a timed event with no end has zero length.
|
||||||
|
pub fn duration(&self) -> TimeDelta {
|
||||||
|
match &self.end {
|
||||||
|
Some(EventEnd::Duration { duration }) => duration.as_time_delta(),
|
||||||
|
Some(EventEnd::DateTime { dtend }) => dtend.naive_local() - self.dtstart.naive_local(),
|
||||||
|
None if self.is_all_day() => TimeDelta::days(1),
|
||||||
|
None => TimeDelta::zero(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end value, computing it from `DURATION` when that is what was stored.
|
||||||
|
pub fn dtend(&self) -> CalendarDateTime {
|
||||||
|
match &self.end {
|
||||||
|
Some(EventEnd::DateTime { dtend }) => dtend.clone(),
|
||||||
|
Some(EventEnd::Duration { duration }) => self.dtstart.shifted(duration.as_time_delta()),
|
||||||
|
None => self.dtstart.shifted(self.duration()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets an explicit end instant.
|
||||||
|
pub fn ending_at(mut self, dtend: CalendarDateTime) -> Self {
|
||||||
|
self.end = Some(EventEnd::DateTime { dtend });
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a duration instead of an end instant.
|
||||||
|
pub fn lasting(mut self, duration: IcalDuration) -> Self {
|
||||||
|
self.end = Some(EventEnd::Duration { duration });
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn titled(mut self, summary: impl Into<String>) -> Self {
|
||||||
|
self.summary = Some(summary.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `STATUS` — RFC 5545 §3.8.1.11, as it applies to events.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum EventStatus {
|
||||||
|
Tentative,
|
||||||
|
#[default]
|
||||||
|
Confirmed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CLASS` — RFC 5545 §3.8.1.3.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum EventClass {
|
||||||
|
#[default]
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TRANSP` — whether the event consumes free/busy time. RFC 5545 §3.8.2.7.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Transparency {
|
||||||
|
/// Blocks time.
|
||||||
|
#[default]
|
||||||
|
Opaque,
|
||||||
|
/// Does not block time.
|
||||||
|
Transparent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PRIORITY` — RFC 5545 §3.8.1.9. Constrained to 0-9 on construction.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(try_from = "u8", into = "u8")]
|
||||||
|
pub struct Priority(u8);
|
||||||
|
|
||||||
|
impl Priority {
|
||||||
|
/// Rejects values outside 0-9 rather than silently clamping them.
|
||||||
|
pub fn new(value: u8) -> Result<Self, InvalidPriority> {
|
||||||
|
if value > 9 {
|
||||||
|
return Err(InvalidPriority(value));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(self) -> u8 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The coarse band the RFC describes: 1-4 high, 5 normal, 6-9 low,
|
||||||
|
/// 0 undefined.
|
||||||
|
pub fn band(self) -> PriorityBand {
|
||||||
|
match self.0 {
|
||||||
|
0 => PriorityBand::Undefined,
|
||||||
|
1..=4 => PriorityBand::High,
|
||||||
|
5 => PriorityBand::Normal,
|
||||||
|
_ => PriorityBand::Low,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<u8> for Priority {
|
||||||
|
type Error = InvalidPriority;
|
||||||
|
|
||||||
|
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Priority> for u8 {
|
||||||
|
fn from(p: Priority) -> Self {
|
||||||
|
p.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("priority must be 0-9, got {0}")]
|
||||||
|
pub struct InvalidPriority(pub u8);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PriorityBand {
|
||||||
|
Undefined,
|
||||||
|
High,
|
||||||
|
Normal,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GEO` — RFC 5545 §3.8.1.6.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GeoPosition {
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! The RFC 5545 domain model.
|
||||||
|
//!
|
||||||
|
//! Every type here is a plain value: serialisable, comparable, and free of I/O.
|
||||||
|
//! This module is the whole of the crate's default feature, so that the
|
||||||
|
//! frontend can share these definitions without compiling the iCalendar parser
|
||||||
|
//! or the recurrence engine into the WASM bundle.
|
||||||
|
|
||||||
|
mod alarm;
|
||||||
|
mod datetime;
|
||||||
|
mod event;
|
||||||
|
mod object;
|
||||||
|
mod person;
|
||||||
|
|
||||||
|
pub use alarm::{AlarmAction, AlarmTrigger, TriggerRelation, VAlarm};
|
||||||
|
pub use datetime::{CalendarDateTime, EventEnd, IcalDuration, InvalidTzId, TzId};
|
||||||
|
pub use event::{
|
||||||
|
EventClass, EventStatus, GeoPosition, InvalidPriority, Priority, PriorityBand, Transparency,
|
||||||
|
VEvent,
|
||||||
|
};
|
||||||
|
pub use object::{CalendarObject, EditScope};
|
||||||
|
pub use person::{Attendee, CalendarUser, CalendarUserType, ParticipationStatus, Role};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! A CalDAV calendar object resource — one `.ics` file on the server.
|
||||||
|
//!
|
||||||
|
//! This is the level at which CalDAV actually operates, and modelling it
|
||||||
|
//! explicitly is what makes correct recurrence handling possible.
|
||||||
|
//!
|
||||||
|
//! A single resource holds a `VCALENDAR` containing one or more `VEVENT`s that
|
||||||
|
//! all share a `UID`: the series master, plus one override per modified
|
||||||
|
//! occurrence, each distinguished by its `RECURRENCE-ID`. The previous
|
||||||
|
//! iteration flattened that structure into a bare list of events, which made
|
||||||
|
//! masters and overrides indistinguishable — so a correctly-formed feed looked
|
||||||
|
//! like it contained duplicates, and ~500 lines of title-matching heuristics
|
||||||
|
//! were added to "clean up" the result by silently discarding events. Keeping
|
||||||
|
//! the resource intact removes the need for any of that.
|
||||||
|
|
||||||
|
use super::event::VEvent;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One addressable resource on a CalDAV server, with its concurrency metadata.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarObject {
|
||||||
|
/// Path of this resource on the server, relative to its host.
|
||||||
|
pub href: String,
|
||||||
|
/// Server-assigned `ETag`. Absent for an object not yet written.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub etag: Option<String>,
|
||||||
|
/// Path of the collection holding this resource.
|
||||||
|
pub calendar_path: String,
|
||||||
|
/// The `VEVENT`s inside, sharing one `UID`: at most one master plus any
|
||||||
|
/// number of overrides.
|
||||||
|
pub events: Vec<VEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarObject {
|
||||||
|
/// The series master — the event without a `RECURRENCE-ID`.
|
||||||
|
///
|
||||||
|
/// Absent when a resource contains only overrides, which is unusual but
|
||||||
|
/// legal and which servers do emit.
|
||||||
|
pub fn master(&self) -> Option<&VEvent> {
|
||||||
|
self.events.iter().find(|e| !e.is_override())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The overrides, each replacing one occurrence of the master.
|
||||||
|
pub fn overrides(&self) -> impl Iterator<Item = &VEvent> {
|
||||||
|
self.events.iter().filter(|e| e.is_override())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `UID` shared by every event in this resource.
|
||||||
|
pub fn uid(&self) -> Option<&str> {
|
||||||
|
self.events.first().map(|e| e.uid.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether every contained event agrees on the `UID`, as RFC 4791 requires.
|
||||||
|
///
|
||||||
|
/// Worth asserting in tests against real servers rather than assuming.
|
||||||
|
pub fn has_consistent_uid(&self) -> bool {
|
||||||
|
let mut uids = self.events.iter().map(|e| e.uid.as_str());
|
||||||
|
match uids.next() {
|
||||||
|
None => true,
|
||||||
|
Some(first) => uids.all(|u| u == first),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which occurrences of a series an edit or deletion applies to.
|
||||||
|
///
|
||||||
|
/// Replaces the previous iteration's stringly-typed dispatch on values like
|
||||||
|
/// `"this_only"`, `"this_and_future"` and `"delete_series"`, which appeared 53
|
||||||
|
/// times across the codebase and turned a typo into a silent runtime
|
||||||
|
/// fallthrough rather than a compile error.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EditScope {
|
||||||
|
/// Just the occurrence named by the request. Realised as an `EXDATE` on the
|
||||||
|
/// master plus a new override carrying a `RECURRENCE-ID`.
|
||||||
|
#[default]
|
||||||
|
ThisOnly,
|
||||||
|
/// This occurrence and everything after it. Realised by truncating the
|
||||||
|
/// master with `UNTIL` and creating a fresh series.
|
||||||
|
ThisAndFuture,
|
||||||
|
/// Every occurrence. Realised by editing the master in place.
|
||||||
|
EntireSeries,
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! Organizers and attendees — RFC 5545 §3.8.4.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A calendar user address plus its display parameters. Used for `ORGANIZER`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarUser {
|
||||||
|
/// The `CAL-ADDRESS` value, normally a `mailto:` URI.
|
||||||
|
pub address: String,
|
||||||
|
/// `CN` — the display name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub common_name: Option<String>,
|
||||||
|
/// `DIR` — a directory entry reference.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dir_entry: Option<String>,
|
||||||
|
/// `SENT-BY` — the address acting on this user's behalf.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sent_by: Option<String>,
|
||||||
|
/// `LANGUAGE`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarUser {
|
||||||
|
pub fn new(address: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
address: address.into(),
|
||||||
|
common_name: None,
|
||||||
|
dir_entry: None,
|
||||||
|
sent_by: None,
|
||||||
|
language: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name to show, falling back to the address.
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
self.common_name.as_deref().unwrap_or(&self.address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `ATTENDEE`, with the parameters that govern scheduling.
|
||||||
|
///
|
||||||
|
/// The previous iteration carried attendees to the server as a single
|
||||||
|
/// comma-separated string, which discarded every parameter below and left a
|
||||||
|
/// `// TODO: Parse attendees properly` on the receiving side.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Attendee {
|
||||||
|
pub address: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub common_name: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub role: Option<Role>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub participation_status: Option<ParticipationStatus>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub user_type: Option<CalendarUserType>,
|
||||||
|
/// `RSVP` — whether a reply is requested.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rsvp: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub member: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub delegated_to: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub delegated_from: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sent_by: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Attendee {
|
||||||
|
pub fn new(address: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
address: address.into(),
|
||||||
|
common_name: None,
|
||||||
|
role: None,
|
||||||
|
participation_status: None,
|
||||||
|
user_type: None,
|
||||||
|
rsvp: None,
|
||||||
|
member: Vec::new(),
|
||||||
|
delegated_to: Vec::new(),
|
||||||
|
delegated_from: Vec::new(),
|
||||||
|
sent_by: None,
|
||||||
|
language: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
self.common_name.as_deref().unwrap_or(&self.address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ROLE` — RFC 5545 §3.2.16.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Role {
|
||||||
|
Chair,
|
||||||
|
#[default]
|
||||||
|
ReqParticipant,
|
||||||
|
OptParticipant,
|
||||||
|
NonParticipant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PARTSTAT` — RFC 5545 §3.2.12.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ParticipationStatus {
|
||||||
|
#[default]
|
||||||
|
NeedsAction,
|
||||||
|
Accepted,
|
||||||
|
Declined,
|
||||||
|
Tentative,
|
||||||
|
Delegated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CUTYPE` — RFC 5545 §3.2.3.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum CalendarUserType {
|
||||||
|
#[default]
|
||||||
|
Individual,
|
||||||
|
Group,
|
||||||
|
Resource,
|
||||||
|
Room,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
//! Behaviour of the domain model, exercised through the public API.
|
||||||
|
//!
|
||||||
|
//! Several of these assert the exact JSON shape. That is deliberate: the model
|
||||||
|
//! is also the wire format, so a change to it is a change to the API contract
|
||||||
|
//! and should have to be made on purpose.
|
||||||
|
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDate, TimeDelta, TimeZone, Utc};
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use runway_core::model::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn naive(y: i32, m: u32, d: u32, h: u32, min: u32) -> chrono::NaiveDateTime {
|
||||||
|
date(y, m, d).and_hms_opt(h, min, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utc(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
|
||||||
|
Utc.with_ymd_and_hms(y, m, d, h, min, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn denver() -> TzId {
|
||||||
|
TzId::new("America/Denver").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- date-times
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_datetime_variant_survives_a_json_round_trip() {
|
||||||
|
let cases = vec![
|
||||||
|
CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25),
|
||||||
|
},
|
||||||
|
CalendarDateTime::Floating {
|
||||||
|
local: naive(2026, 12, 25, 9, 30),
|
||||||
|
},
|
||||||
|
CalendarDateTime::Utc {
|
||||||
|
utc: utc(2026, 12, 25, 9, 30),
|
||||||
|
},
|
||||||
|
CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 25, 9, 30),
|
||||||
|
tzid: denver(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for original in cases {
|
||||||
|
let encoded = serde_json::to_string(&original).unwrap();
|
||||||
|
let decoded: CalendarDateTime = serde_json::from_str(&encoded).unwrap();
|
||||||
|
assert_eq!(original, decoded, "round trip failed for {encoded}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zoned_datetime_json_names_its_zone() {
|
||||||
|
let value = CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 25, 9, 30),
|
||||||
|
tzid: denver(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&value).unwrap(),
|
||||||
|
json!({
|
||||||
|
"kind": "zoned",
|
||||||
|
"local": "2026-12-25T09:30:00",
|
||||||
|
"tzid": "America/Denver",
|
||||||
|
}),
|
||||||
|
"the wire format must carry an IANA zone, never a bare UTC offset: \
|
||||||
|
an offset cannot distinguish standard time from daylight time, which \
|
||||||
|
is what made recurring events drift across DST in the last iteration",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn date_only_values_are_recognisable_without_a_separate_flag() {
|
||||||
|
assert!(
|
||||||
|
CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25)
|
||||||
|
}
|
||||||
|
.is_date_only()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!CalendarDateTime::Utc {
|
||||||
|
utc: utc(2026, 12, 25, 9, 0)
|
||||||
|
}
|
||||||
|
.is_date_only()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shifting_preserves_the_variant_and_the_zone() {
|
||||||
|
let zoned = CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 25, 9, 0),
|
||||||
|
tzid: denver(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let shifted = zoned.shifted(TimeDelta::hours(2));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shifted,
|
||||||
|
CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 25, 11, 0),
|
||||||
|
tzid: denver()
|
||||||
|
},
|
||||||
|
"moving an event must not silently change what zone it is expressed in",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shifting_a_date_only_value_moves_whole_days() {
|
||||||
|
let all_day = CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
all_day.shifted(TimeDelta::days(3)),
|
||||||
|
CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 28)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_time_zone_identifiers_are_rejected() {
|
||||||
|
assert!(TzId::new("").is_err());
|
||||||
|
assert!(TzId::new(" ").is_err());
|
||||||
|
assert!(TzId::new("America/Denver").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- durations
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durations_are_carried_as_whole_seconds() {
|
||||||
|
let fifteen_minutes = IcalDuration::minutes(15).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(serde_json::to_value(fifteen_minutes).unwrap(), json!(900));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_durations_round_trip() {
|
||||||
|
let before = IcalDuration::minutes(-15).unwrap();
|
||||||
|
|
||||||
|
let decoded: IcalDuration =
|
||||||
|
serde_json::from_value(serde_json::to_value(before).unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded, before);
|
||||||
|
assert_eq!(decoded.as_time_delta(), TimeDelta::minutes(-15));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- events
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_day_is_derived_from_the_start_value() {
|
||||||
|
let all_day = VEvent::new(CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25),
|
||||||
|
});
|
||||||
|
let timed = VEvent::new(CalendarDateTime::Utc {
|
||||||
|
utc: utc(2026, 12, 25, 9, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(all_day.is_all_day());
|
||||||
|
assert!(!timed.is_all_day());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_all_day_event_without_an_end_lasts_one_day() {
|
||||||
|
let event = VEvent::new(CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(event.duration(), TimeDelta::days(1));
|
||||||
|
assert_eq!(
|
||||||
|
event.dtend(),
|
||||||
|
CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 26)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_timed_event_without_an_end_has_no_duration() {
|
||||||
|
let event = VEvent::new(CalendarDateTime::Utc {
|
||||||
|
utc: utc(2026, 12, 25, 9, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
event.duration(),
|
||||||
|
TimeDelta::zero(),
|
||||||
|
"RFC 5545 gives no default length for a timed event; inventing one \
|
||||||
|
(the last iteration assumed an hour) silently corrupts imported data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_end_expressed_as_a_duration_resolves_to_an_instant() {
|
||||||
|
let event = VEvent::new(CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 25, 9, 0),
|
||||||
|
tzid: denver(),
|
||||||
|
})
|
||||||
|
.lasting(IcalDuration::hours(2).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(event.duration(), TimeDelta::hours(2));
|
||||||
|
assert_eq!(
|
||||||
|
event.dtend(),
|
||||||
|
CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 25, 11, 0),
|
||||||
|
tzid: denver()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_end_wins_over_any_computation() {
|
||||||
|
let event = VEvent::new(CalendarDateTime::Utc {
|
||||||
|
utc: utc(2026, 12, 25, 9, 0),
|
||||||
|
})
|
||||||
|
.ending_at(CalendarDateTime::Utc {
|
||||||
|
utc: utc(2026, 12, 25, 17, 30),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
event.duration(),
|
||||||
|
TimeDelta::hours(8) + TimeDelta::minutes(30)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_summary_counts_as_no_title() {
|
||||||
|
let mut event = VEvent::new(CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25),
|
||||||
|
});
|
||||||
|
assert_eq!(event.title(), None);
|
||||||
|
|
||||||
|
event.summary = Some(" ".to_owned());
|
||||||
|
assert_eq!(event.title(), None);
|
||||||
|
|
||||||
|
event.summary = Some("Dentist".to_owned());
|
||||||
|
assert_eq!(event.title(), Some("Dentist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recurrence_is_distinguished_from_being_an_override() {
|
||||||
|
let mut series = VEvent::new(CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25),
|
||||||
|
});
|
||||||
|
series.rrule = Some("FREQ=WEEKLY;BYDAY=FR".to_owned());
|
||||||
|
|
||||||
|
let mut exception = VEvent::with_uid(
|
||||||
|
&series.uid,
|
||||||
|
CalendarDateTime::Date {
|
||||||
|
date: date(2027, 1, 1),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
exception.recurrence_id = Some(CalendarDateTime::Date {
|
||||||
|
date: date(2027, 1, 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(series.is_recurring() && !series.is_override());
|
||||||
|
assert!(exception.is_override() && !exception.is_recurring());
|
||||||
|
assert_eq!(
|
||||||
|
series.uid, exception.uid,
|
||||||
|
"an override shares the UID of the series it modifies; the last \
|
||||||
|
iteration instead minted synthetic UIDs and then tried to recover the \
|
||||||
|
original by splitting on the final hyphen",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fully_populated_event_survives_a_json_round_trip() {
|
||||||
|
let mut event = VEvent::new(CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 25, 9, 0),
|
||||||
|
tzid: denver(),
|
||||||
|
})
|
||||||
|
.titled("Quarterly review")
|
||||||
|
.lasting(IcalDuration::hours(1).unwrap());
|
||||||
|
|
||||||
|
event.description = Some("Bring the numbers.\nAnd coffee.".to_owned());
|
||||||
|
event.location = Some("Room 3".to_owned());
|
||||||
|
event.status = Some(EventStatus::Tentative);
|
||||||
|
event.class = Some(EventClass::Private);
|
||||||
|
event.transparency = Some(Transparency::Transparent);
|
||||||
|
event.priority = Some(Priority::new(2).unwrap());
|
||||||
|
event.organizer = Some(CalendarUser::new("mailto:me@example.com"));
|
||||||
|
event.attendees = vec![Attendee {
|
||||||
|
common_name: Some("Alex".to_owned()),
|
||||||
|
role: Some(Role::ReqParticipant),
|
||||||
|
participation_status: Some(ParticipationStatus::Accepted),
|
||||||
|
rsvp: Some(true),
|
||||||
|
..Attendee::new("mailto:alex@example.com")
|
||||||
|
}];
|
||||||
|
event.categories = vec!["work".to_owned(), "finance".to_owned()];
|
||||||
|
event.geo = Some(GeoPosition {
|
||||||
|
latitude: 39.7392,
|
||||||
|
longitude: -104.9903,
|
||||||
|
});
|
||||||
|
event.rrule = Some("FREQ=MONTHLY;BYDAY=1FR".to_owned());
|
||||||
|
event.exdate = vec![CalendarDateTime::Zoned {
|
||||||
|
local: naive(2027, 2, 5, 9, 0),
|
||||||
|
tzid: denver(),
|
||||||
|
}];
|
||||||
|
event.sequence = 3;
|
||||||
|
event.alarms = vec![VAlarm::display_before(
|
||||||
|
IcalDuration::minutes(-15).unwrap(),
|
||||||
|
"Quarterly review",
|
||||||
|
)];
|
||||||
|
|
||||||
|
let encoded = serde_json::to_string(&event).unwrap();
|
||||||
|
let decoded: VEvent = serde_json::from_str(&encoded).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(event, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_fields_stay_out_of_the_wire_format() {
|
||||||
|
let event = VEvent::with_uid(
|
||||||
|
"abc",
|
||||||
|
CalendarDateTime::Date {
|
||||||
|
date: date(2026, 12, 25),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let encoded = serde_json::to_value(&event).unwrap();
|
||||||
|
// serde_json orders object keys, so compare as a sorted set.
|
||||||
|
let mut keys: Vec<&str> = encoded
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.keys()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect();
|
||||||
|
keys.sort_unstable();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
keys,
|
||||||
|
vec![
|
||||||
|
"created",
|
||||||
|
"dtstamp",
|
||||||
|
"dtstart",
|
||||||
|
"last_modified",
|
||||||
|
"sequence",
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"an empty event should not serialise a dozen nulls and empty arrays",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ priority
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn priority_outside_the_permitted_range_is_rejected() {
|
||||||
|
assert!(Priority::new(0).is_ok());
|
||||||
|
assert!(Priority::new(9).is_ok());
|
||||||
|
assert!(Priority::new(10).is_err());
|
||||||
|
assert!(Priority::new(255).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_out_of_range_priority_is_rejected_at_the_api_boundary_too() {
|
||||||
|
let result: Result<Priority, _> = serde_json::from_value(json!(10));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"validation must hold on deserialisation, not just on construction, \
|
||||||
|
or invalid values enter through the API and bypass the type",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn priority_bands_follow_the_rfc() {
|
||||||
|
let band = |n: u8| Priority::new(n).unwrap().band();
|
||||||
|
|
||||||
|
assert_eq!(band(0), PriorityBand::Undefined);
|
||||||
|
assert_eq!(band(1), PriorityBand::High);
|
||||||
|
assert_eq!(band(4), PriorityBand::High);
|
||||||
|
assert_eq!(band(5), PriorityBand::Normal);
|
||||||
|
assert_eq!(band(6), PriorityBand::Low);
|
||||||
|
assert_eq!(band(9), PriorityBand::Low);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- alarms
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reminder_before_the_event_carries_a_negative_offset() {
|
||||||
|
let alarm = VAlarm::display_before(IcalDuration::minutes(-15).unwrap(), "Stand-up");
|
||||||
|
|
||||||
|
match alarm.trigger {
|
||||||
|
AlarmTrigger::Relative { offset, related } => {
|
||||||
|
assert!(
|
||||||
|
offset.as_time_delta() < TimeDelta::zero(),
|
||||||
|
"RFC 5545 expresses 'before' as a negative offset; the sign \
|
||||||
|
convention is easy to invert and fires alarms too late",
|
||||||
|
);
|
||||||
|
assert_eq!(related, TriggerRelation::Start);
|
||||||
|
}
|
||||||
|
AlarmTrigger::Absolute { .. } => panic!("expected a relative trigger"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alarm_triggers_round_trip_in_both_forms() {
|
||||||
|
let relative = AlarmTrigger::Relative {
|
||||||
|
offset: IcalDuration::minutes(-10).unwrap(),
|
||||||
|
related: TriggerRelation::End,
|
||||||
|
};
|
||||||
|
let absolute = AlarmTrigger::Absolute {
|
||||||
|
at: utc(2026, 12, 25, 8, 45),
|
||||||
|
};
|
||||||
|
|
||||||
|
for trigger in [relative, absolute] {
|
||||||
|
let encoded = serde_json::to_string(&trigger).unwrap();
|
||||||
|
let decoded: AlarmTrigger = serde_json::from_str(&encoded).unwrap();
|
||||||
|
assert_eq!(trigger, decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- objects
|
||||||
|
|
||||||
|
fn series_with_one_override() -> CalendarObject {
|
||||||
|
let mut master = VEvent::with_uid(
|
||||||
|
"shared-uid",
|
||||||
|
CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 4, 9, 0),
|
||||||
|
tzid: denver(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
master.rrule = Some("FREQ=WEEKLY;BYDAY=FR".to_owned());
|
||||||
|
|
||||||
|
let mut moved = VEvent::with_uid(
|
||||||
|
"shared-uid",
|
||||||
|
CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 11, 14, 0),
|
||||||
|
tzid: denver(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
moved.recurrence_id = Some(CalendarDateTime::Zoned {
|
||||||
|
local: naive(2026, 12, 11, 9, 0),
|
||||||
|
tzid: denver(),
|
||||||
|
});
|
||||||
|
|
||||||
|
CalendarObject {
|
||||||
|
href: "/calendars/connor/personal/shared-uid.ics".to_owned(),
|
||||||
|
etag: Some("\"abc123\"".to_owned()),
|
||||||
|
calendar_path: "/calendars/connor/personal/".to_owned(),
|
||||||
|
events: vec![master, moved],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resource_separates_its_master_from_its_overrides() {
|
||||||
|
let object = series_with_one_override();
|
||||||
|
|
||||||
|
assert!(object.master().is_some());
|
||||||
|
assert_eq!(object.overrides().count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
object.events.len(),
|
||||||
|
2,
|
||||||
|
"a master and its override are two VEVENTs in one resource, not a \
|
||||||
|
duplicate to be deduplicated away",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_event_in_a_resource_shares_one_uid() {
|
||||||
|
let object = series_with_one_override();
|
||||||
|
|
||||||
|
assert_eq!(object.uid(), Some("shared-uid"));
|
||||||
|
assert!(object.has_consistent_uid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resource_with_mismatched_uids_is_detected() {
|
||||||
|
let mut object = series_with_one_override();
|
||||||
|
object.events[1].uid = "different".to_owned();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!object.has_consistent_uid(),
|
||||||
|
"RFC 4791 requires one UID per resource; detecting a violation beats \
|
||||||
|
guessing which event was meant",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resource_holding_only_overrides_has_no_master() {
|
||||||
|
let mut object = series_with_one_override();
|
||||||
|
object.events.retain(VEvent::is_override);
|
||||||
|
|
||||||
|
assert!(object.master().is_none());
|
||||||
|
assert_eq!(object.overrides().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- edit scope
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_scope_uses_a_stable_wire_representation() {
|
||||||
|
let cases = [
|
||||||
|
(EditScope::ThisOnly, json!("this_only")),
|
||||||
|
(EditScope::ThisAndFuture, json!("this_and_future")),
|
||||||
|
(EditScope::EntireSeries, json!("entire_series")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (scope, expected) in cases {
|
||||||
|
assert_eq!(serde_json::to_value(scope).unwrap(), expected);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<EditScope>(expected).unwrap(),
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_edit_scope_is_rejected_rather_than_defaulted() {
|
||||||
|
let result: Result<EditScope, _> = serde_json::from_value(json!("delete_everything"));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"the last iteration matched these as strings with a catch-all arm, so \
|
||||||
|
a typo silently fell through to the wrong behaviour",
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user