feat: support safe recurrence additions

Parse, persist, expand, and round-trip compatible RDATE values while preserving whole-series TUI behavior and conservative rewrite guards.

Verified: normal, warning-as-error, and ASan/UBSan builds pass all 9 suites; storage failure paths preserve calendar and backup bytes; live PTY restores terminal state.
This commit is contained in:
2026-07-18 10:14:53 +01:00
parent 989332ef9a
commit cfca6ab6d0
16 changed files with 498 additions and 69 deletions

View File

@@ -473,6 +473,8 @@ void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) {
&& reloaded.events[index].time_basis == loaded.events[index].time_basis
&& reloaded.events[index].time_zone == loaded.events[index].time_zone
&& reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions,
"time-basis semantic round trip differs");
@@ -541,12 +543,115 @@ void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
"recurrence semantic round-trip event count differs");
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
require(reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions,
"recurrence semantic round trip differs");
}
}
void test_supported_rdates(const std::filesystem::path& root) {
const auto path = root / "rdates.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:utc-rdate\r\nSUMMARY:UTC additions\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RDATE:20260718T090000Z,20260720T090000Z\r\n"
"RDATE:20260722T090000Z\r\nEXDATE:20260720T090000Z\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:floating-rdate\r\nSUMMARY:Floating additions\r\n"
"DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\n"
"RDATE:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:zoned-rdate\r\nSUMMARY:Zoned additions\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\n"
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:all-day-rdate\r\nSUMMARY:All-day additions\r\n"
"DTSTART;VALUE=DATE:20260717\r\nDTEND;VALUE=DATE:20260718\r\n"
"RDATE;VALUE=DATE:20260719,20260721\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:mixed-rdate\r\nSUMMARY:Rule and additions\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20260725T090000Z,20260727T090000Z\r\n"
"EXDATE:20260718T090000Z,20260725T090000Z\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"supported RDATE values were not rewrite-safe");
require(loaded.events.size() == 5, "RDATE fixture event count differs");
require(!loaded.events[0].recurrence.has_value(),
"RDATE-only event acquired an RRULE");
require(loaded.events[0].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h,
sys_days{year{2026}/July/20} + 9h,
sys_days{year{2026}/July/22} + 9h},
"multiple or comma-separated UTC RDATE values were not collected");
require(loaded.events[0].recurrence_exceptions
== std::vector<nocal::TimePoint>{sys_days{year{2026}/July/20} + 9h},
"RDATE-only EXDATE value was not retained");
require(loaded.events[1].time_basis == nocal::TimeBasis::floating
&& loaded.events[1].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 10h + 15min,
sys_days{year{2026}/July/19} + 10h + 15min},
"floating RDATE values did not preserve their time basis");
require(loaded.events[2].time_basis == nocal::TimeBasis::zoned
&& loaded.events[2].time_zone == "Europe/London"
&& loaded.events[2].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h + 15min,
sys_days{year{2026}/July/19} + 9h + 15min},
"zoned RDATE values were not converted with DTSTART's TZID");
require(loaded.events[3].all_day
&& loaded.events[3].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/19}, sys_days{year{2026}/July/21}},
"all-day RDATE values were not parsed as DATE values");
require(loaded.events[4].recurrence.has_value()
&& loaded.events[4].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/25} + 9h,
sys_days{year{2026}/July/27} + 9h}
&& loaded.events[4].recurrence_exceptions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h,
sys_days{year{2026}/July/25} + 9h},
"mixed RRULE, RDATE, and EXDATE values differ");
const auto round_trip = root / "rdates-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find(
"RDATE:20260718T090000Z,20260720T090000Z,20260722T090000Z")
!= std::string::npos,
"UTC RDATE values were not grouped on serialization");
require(serialized.find("RDATE:20260718T101500,20260719T101500")
!= std::string::npos,
"floating RDATE values were not serialized without a suffix");
require(serialized.find(
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500")
!= std::string::npos,
"zoned RDATE values were not serialized with TZID");
require(serialized.find("RDATE;VALUE=DATE:20260719,20260721")
!= std::string::npos,
"all-day RDATE values were not serialized with VALUE=DATE");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
"serialized RDATE calendar did not reload safely");
require(reloaded.events.size() == loaded.events.size(),
"RDATE semantic round-trip event count differs");
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
require(reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions,
"RDATE semantic round trip differs");
}
}
void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) {
const std::vector<std::string> unsupported_properties{
"RRULE:FREQ=WEEKLY;BYDAY=1MO",
@@ -554,7 +659,7 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
"RRULE:FREQ=HOURLY",
"RRULE:FREQ=DAILY;WKST=MO",
"RDATE:20260718T090000Z",
"RDATE;VALUE=PERIOD:20260718T090000Z/PT1H",
"RECURRENCE-ID:20260718T090000Z"};
for (std::size_t index = 0; index < unsupported_properties.size(); ++index) {
const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics");
@@ -595,6 +700,37 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite,
"VTIMEZONE definition was considered rewrite-safe");
struct InvalidRdate {
std::string start;
std::string end;
std::string property;
};
const std::vector<InvalidRdate> invalid_rdates{
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE:20260718T090000"},
{"DTSTART;TZID=Europe/London:20260717T090000",
"DTEND;TZID=Europe/London:20260717T100000",
"RDATE;TZID=Europe/Paris:20260718T090000"},
{"DTSTART;VALUE=DATE:20260717", "DTEND;VALUE=DATE:20260718",
"RDATE:20260719T090000Z"},
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE;TZID=Custom/Mars:20260718T090000"},
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE:not-a-date"}};
for (std::size_t index = 0; index < invalid_rdates.size(); ++index) {
const auto path = root / ("invalid-rdate-" + std::to_string(index) + ".ics");
const auto& fixture = invalid_rdates[index];
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:invalid-rdate\r\n"
+ fixture.start + "\r\n" + fixture.end + "\r\n" + fixture.property
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite && !loaded.warnings.empty(),
"invalid RDATE was considered rewrite-safe");
require(loaded.events.size() == 1 && loaded.events[0].recurrence_additions.empty(),
"invalid RDATE prevented base-event browsing or became an occurrence");
}
const std::vector<std::pair<std::string, std::string>> mismatched_ends{
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000"},
{"DTSTART;TZID=Europe/London:20260717T090000",
@@ -660,7 +796,20 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
}
require(exdate_failed && read_fixture(path) == original
&& read_fixture(backup) == original_backup,
"EXDATE-without-RRULE validation changed calendar or backup bytes");
"EXDATE-without-RRULE-or-RDATE validation changed calendar or backup bytes");
nocal::Event duplicate_rdate = event_named("rdate@example.test", "Duplicate RDATE");
duplicate_rdate.recurrence_additions = {
duplicate_rdate.start + 24h, duplicate_rdate.start + 24h};
bool rdate_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{duplicate_rdate});
} catch (const std::invalid_argument&) {
rdate_failed = true;
}
require(rdate_failed && read_fixture(path) == original
&& read_fixture(backup) == original_backup,
"duplicate RDATE validation changed calendar or backup bytes");
require_no_temporary_files(path);
}
@@ -691,6 +840,7 @@ int main() {
test_unsupported_data_is_not_rewrite_safe(root);
test_time_basis_round_trips_and_dst(root);
test_supported_recurrence_and_exdates(root);
test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root);
test_save_validation_preserves_existing_bytes(root);
std::filesystem::remove_all(root, ignored);