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:
2026-08-26 12:29:59 -04:00
parent bf63024711
commit 52cd5a961d
10 changed files with 1344 additions and 4 deletions
+521
View File
@@ -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",
);
}