diff --git a/crates/runway-core/Cargo.toml b/crates/runway-core/Cargo.toml index 70f185d..d8a607e 100644 --- a/crates/runway-core/Cargo.toml +++ b/crates/runway-core/Cargo.toml @@ -23,6 +23,12 @@ chrono-tz = { workspace = true, optional = true } icalendar = { 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] serde_json = { workspace = true } pretty_assertions = { workspace = true } diff --git a/crates/runway-core/src/lib.rs b/crates/runway-core/src/lib.rs index e58a015..332cdb8 100644 --- a/crates/runway-core/src/lib.rs +++ b/crates/runway-core/src/lib.rs @@ -1,8 +1,16 @@ //! Pure calendar domain logic for Runway. //! //! 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 -//! of HTTP, CalDAV or the database, which keeps it fast to test and — with only -//! the default `model` feature — cheap enough to compile into the WASM bundle. +//! round-trip and recurrence expansion. It performs no I/O and knows nothing of +//! HTTP, CalDAV or the database, which keeps it fast to test and — with only the +//! 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 use model::*; diff --git a/crates/runway-core/src/model.rs b/crates/runway-core/src/model.rs deleted file mode 100644 index 85919c5..0000000 --- a/crates/runway-core/src/model.rs +++ /dev/null @@ -1 +0,0 @@ -pub struct Placeholder; diff --git a/crates/runway-core/src/model/alarm.rs b/crates/runway-core/src/model/alarm.rs new file mode 100644 index 0000000..d436bc3 --- /dev/null +++ b/crates/runway-core/src/model/alarm.rs @@ -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, + /// `REPEAT` — how many additional times to fire. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repeat: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attendees: Vec, +} + +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) -> 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 }, +} + +/// `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, +} diff --git a/crates/runway-core/src/model/datetime.rs b/crates/runway-core/src/model/datetime.rs new file mode 100644 index 0000000..0bebf05 --- /dev/null +++ b/crates/runway-core/src/model/datetime.rs @@ -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` 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) -> Result { + 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 }, + + /// 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 { + TimeDelta::try_minutes(n).map(Self) + } + + pub fn hours(n: i64) -> Option { + TimeDelta::try_hours(n).map(Self) + } + + pub fn as_time_delta(self) -> TimeDelta { + self.0 + } +} + +impl From for IcalDuration { + fn from(d: TimeDelta) -> Self { + Self(d) + } +} + +mod seconds { + use chrono::TimeDelta; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(d: &TimeDelta, s: S) -> Result { + s.serialize_i64(d.num_seconds()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let secs = i64::deserialize(d)?; + TimeDelta::try_seconds(secs) + .ok_or_else(|| serde::de::Error::custom(format!("duration out of range: {secs}s"))) + } +} diff --git a/crates/runway-core/src/model/event.rs b/crates/runway-core/src/model/event.rs new file mode 100644 index 0000000..92f3de1 --- /dev/null +++ b/crates/runway-core/src/model/event.rs @@ -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, + /// `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, + + // ---- Description ---- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub geo: Option, + + // ---- Classification ---- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transparency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + + // ---- People ---- + #[serde(default, skip_serializing_if = "Option::is_none")] + pub organizer: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attendees: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contact: Option, + + // ---- Grouping ---- + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub categories: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub resources: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub related_to: Option, + + // ---- 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, + /// `RDATE` — extra occurrences. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rdate: Vec, + /// `EXDATE` — occurrences removed from the series. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exdate: Vec, + /// `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, + + // ---- Bookkeeping ---- + /// `SEQUENCE` — bumped on each significant revision. + #[serde(default)] + pub sequence: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_modified: Option>, + + // ---- Sub-components ---- + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub alarms: Vec, +} + +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, 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) -> 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 { + 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 for Priority { + type Error = InvalidPriority; + + fn try_from(value: u8) -> Result { + Self::new(value) + } +} + +impl From 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, +} diff --git a/crates/runway-core/src/model/mod.rs b/crates/runway-core/src/model/mod.rs new file mode 100644 index 0000000..aa0110b --- /dev/null +++ b/crates/runway-core/src/model/mod.rs @@ -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}; diff --git a/crates/runway-core/src/model/object.rs b/crates/runway-core/src/model/object.rs new file mode 100644 index 0000000..771e2ba --- /dev/null +++ b/crates/runway-core/src/model/object.rs @@ -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, + /// 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, +} + +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 { + 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, +} diff --git a/crates/runway-core/src/model/person.rs b/crates/runway-core/src/model/person.rs new file mode 100644 index 0000000..d97512d --- /dev/null +++ b/crates/runway-core/src/model/person.rs @@ -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, + /// `DIR` — a directory entry reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dir_entry: Option, + /// `SENT-BY` — the address acting on this user's behalf. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sent_by: Option, + /// `LANGUAGE`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, +} + +impl CalendarUser { + pub fn new(address: impl Into) -> 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub participation_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_type: Option, + /// `RSVP` — whether a reply is requested. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rsvp: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub member: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delegated_to: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delegated_from: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sent_by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, +} + +impl Attendee { + pub fn new(address: impl Into) -> 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, +} diff --git a/crates/runway-core/tests/model.rs b/crates/runway-core/tests/model.rs new file mode 100644 index 0000000..01e9558 --- /dev/null +++ b/crates/runway-core/tests/model.rs @@ -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.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 = 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::(expected).unwrap(), + scope + ); + } +} + +#[test] +fn an_unknown_edit_scope_is_rejected_rather_than_defaulted() { + let result: Result = 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", + ); +}