From b524e6e33d93cecc36f78809816b8a00eedbb80c Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 18 Jul 2026 09:32:54 +0100 Subject: [PATCH] feat: add safe calendar import and export Add explicit noninteractive CLI transfer modes with full event validation, idempotent duplicate handling, collision refusal, atomic import backups, and no-overwrite export creation. Cover pure merge semantics and end-to-end rollback paths for unsupported content, invalid identities, conflicts, option combinations, and existing destinations. --- README.md | 27 +- docs/ARCHITECTURE.md | 19 ++ docs/PRODUCT.md | 23 +- docs/ROADMAP.md | 3 +- include/nocal/domain/calendar_transfer.hpp | 28 +++ include/nocal/domain/domain.hpp | 1 + meson.build | 7 + src/domain/calendar_transfer.cpp | 93 +++++++ src/main.cpp | 115 ++++++++- tests/calendar_transfer_tests.cpp | 279 ++++++++++++++++++++ tests/cli_transfer_test.sh | 280 +++++++++++++++++++++ 11 files changed, 867 insertions(+), 8 deletions(-) create mode 100644 include/nocal/domain/calendar_transfer.hpp create mode 100644 src/domain/calendar_transfer.cpp create mode 100644 tests/calendar_transfer_tests.cpp create mode 100644 tests/cli_transfer_test.sh diff --git a/README.md b/README.md index a1a476f..da8cbd6 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ inherits its colors from your terminal theme. This repository currently contains the first local-calendar vertical slice: date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms, -a responsive ANSI terminal UI with month and agenda views, tests, and Linux -launch integration. See [the product specification](docs/PRODUCT.md), +a responsive ANSI terminal UI with month and agenda views, explicit safe +calendar import/export, tests, and Linux launch integration. See +[the product specification](docs/PRODUCT.md), [architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md). ## Controls @@ -46,6 +47,28 @@ Run the tests with `meson test -C build --print-errorlogs`. Nocal reads positional argument. `--demo` adds a few unsaved sample appointments and `--print` emits a non-interactive frame. +Calendar interchange is explicit, non-interactive, and network-free: + +```sh +nocal --import SOURCE.ics --calendar TARGET.ics +nocal --export DESTINATION.ics --calendar SOURCE.ics +``` + +Import atomically merges source events into the target. An event whose UID and +all fields exactly match an existing event is skipped. The complete operation +is rejected before the target is written if either file is unsafe to +round-trip or contains empty or duplicate UIDs or invalid intervals, or if an +imported UID collides with a different target event. A successful merge that +adds events retains the target's exact previous bytes as `TARGET.ics.bak`; an +import containing only exact duplicates does not rewrite the target or its +backup. + +Export validates the source and atomically creates a canonical calendar at the +destination. It refuses to overwrite any existing destination and rejects a +source that fails event validation or that Nocal cannot safely round-trip. +`--import` and `--export` are mutually exclusive and cannot be combined with +`--demo`, `--print`, or `--restore-backup`. + Nocal expands and safely round-trips a focused iCalendar recurrence subset: daily, weekly, monthly, and yearly rules with interval, count or until, common weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4c7bb35..89f59b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,6 +82,25 @@ does not consume the backup. The TUI mutates a copyable in-memory model and rolls it back if persistence fails. Its bounded undo/redo history contains only mutations that crossed the persistence boundary successfully. +Explicit CLI import and export reuse the storage parser, safety classification, +validation, and atomic persistence boundary; these workflows do not depend on +the TUI and perform no network access. Import loads and validates both source +and target before staging a target replacement. UIDs in both files must be +non-empty and unique, every interval must be valid, and every component in both +files must be safe to round-trip. Matching UID plus identical fields is an +idempotent duplicate; matching UID with different fields is a collision that +aborts the whole operation. A committed merge writes the exact old target to +`.bak` before atomic replacement. If every source event is already present +exactly, import performs no write and does not alter the backup. + +Export accepts only a valid, safely round-trippable source, serializes a +canonical calendar through private staging, and atomically creates a destination +that must not already exist. Destination refusal is part of the +commit boundary, so export never replaces an existing path. Import, export, +demo, non-interactive frame printing, and backup restoration are distinct +top-level application modes; import and export cannot be selected together or +combined with any of the other modes. + The current writer deliberately refuses to mutate an existing file when the loader encounters information it cannot round-trip, including recurrence overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 13b2120..84e81c5 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -81,7 +81,7 @@ The full terminal is treated as a responsive canvas: The first usable foundation supports day and appointment navigation, a full-frame reader, a read-only 42-day agenda, validated add/edit/delete forms, -and atomic `.ics` writes. +atomic `.ics` writes, and explicit non-interactive calendar import/export. Dense days keep every appointment keyboard-reachable even when all lines do not fit in the cell. Unknown or unsupported iCalendar content makes a source read-only instead of being discarded. Saves reject external changes, retain a @@ -101,6 +101,27 @@ agenda. Agenda navigation never persists state, and add/edit/delete remain in the month workflow. The next local-data work adds advanced recurrence overrides and rule editing. +## Local file interchange + +Import and export are explicit command-line workflows. They never contact the +network and do not enter the interactive TUI. + +- `nocal --import SOURCE --calendar TARGET` atomically merges events into the + target. Exact same-UID, same-field events are duplicates and are skipped. A + differing same-UID event, an empty or duplicate UID or invalid event interval + in either file, or content in either file that cannot be safely round-tripped + rejects the complete import before the target is written. A successful + merge keeps the target's exact previous bytes as its adjacent `.bak`; a + duplicate-only import leaves both target and backup untouched. +- `nocal --export DESTINATION --calendar SOURCE` validates the source and + atomically creates a canonical calendar. It never overwrites an existing + destination and refuses any source whose events are invalid or that cannot + be safely round-tripped. + +The two operations are mutually exclusive and cannot be combined with demo, +frame printing, or backup restoration. These restrictions keep destructive +ambiguity out of automation and make every file-writing mode deliberate. + ## Accessibility - Information is never encoded by color alone. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 11d121b..2426217 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,13 +12,14 @@ - Occurrence-aware appointment search with exact result-to-grid navigation - Session calendar toggles shared by month rendering, focus, and search - Read-only 42-day agenda with bounded recurrence and exact return navigation +- Explicit atomic import/export with validation, collision refusal, and backups - Meson build, Nix development shell, desktop entry, and Hyprland launcher - Seeded sample data when explicitly requested, never silent data mutation ## 0.1 — complete local calendar - Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support -- Import/export and configurable week start +- Configurable week start - Screen-reader-friendly linear view and full Unicode display-width handling ## 0.2 — durable cache and provider boundary diff --git a/include/nocal/domain/calendar_transfer.hpp b/include/nocal/domain/calendar_transfer.hpp new file mode 100644 index 0000000..6140637 --- /dev/null +++ b/include/nocal/domain/calendar_transfer.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "nocal/domain/event.hpp" + +#include +#include +#include + +namespace nocal { + +struct ImportMergeResult { + std::vector events; + std::size_t imported{}; + std::size_t duplicates_skipped{}; +}; + +// Transfer inputs must contain valid intervals and one non-empty, unique UID +// per event. Invalid input throws std::invalid_argument before any persistence +// boundary is crossed. +void validate_events_for_export(std::span events); + +// Preserves target order and appends new source events in source order. An +// exactly equivalent source/target UID is skipped; a differing UID collision +// throws std::invalid_argument and returns no partial result. +[[nodiscard]] ImportMergeResult merge_events_for_import( + std::span target, std::span source); + +} // namespace nocal diff --git a/include/nocal/domain/domain.hpp b/include/nocal/domain/domain.hpp index ce7eb44..ecd4c7f 100644 --- a/include/nocal/domain/domain.hpp +++ b/include/nocal/domain/domain.hpp @@ -1,5 +1,6 @@ #pragma once +#include "nocal/domain/calendar_transfer.hpp" #include "nocal/domain/date.hpp" #include "nocal/domain/event.hpp" #include "nocal/domain/event_query.hpp" diff --git a/meson.build b/meson.build index 0dd5cae..3c2df2c 100644 --- a/meson.build +++ b/meson.build @@ -13,6 +13,7 @@ project( inc = include_directories('include') nocal_sources = files( + 'src/domain/calendar_transfer.cpp', 'src/domain/date.cpp', 'src/domain/event_query.cpp', 'src/domain/month_layout.cpp', @@ -30,6 +31,10 @@ nocal_executable = executable('nocal', 'src/main.cpp', include_directories: inc, domain_tests = executable('domain-tests', 'tests/domain_tests.cpp', include_directories: inc, link_with: nocal_lib) test('domain', domain_tests) +calendar_transfer_tests = executable('calendar-transfer-tests', + 'tests/calendar_transfer_tests.cpp', include_directories: inc, + link_with: nocal_lib) +test('calendar-transfer', calendar_transfer_tests) ics_tests = executable('ics-tests', 'tests/ics_tests.cpp', include_directories: inc, link_with: nocal_lib) test('ics-storage', ics_tests) @@ -46,6 +51,8 @@ test('editor', editor_tests) shell = find_program('sh') test('cli-recovery', shell, args: [files('tests/cli_recovery_test.sh'), nocal_executable]) +test('cli-transfer', shell, + args: [files('tests/cli_transfer_test.sh'), nocal_executable]) install_data('scripts/nocal-hyprland', install_dir: get_option('bindir'), install_mode: 'rwxr-xr-x') diff --git a/src/domain/calendar_transfer.cpp b/src/domain/calendar_transfer.cpp new file mode 100644 index 0000000..b385c06 --- /dev/null +++ b/src/domain/calendar_transfer.cpp @@ -0,0 +1,93 @@ +#include "nocal/domain/calendar_transfer.hpp" + +#include +#include +#include +#include +#include + +namespace nocal { +namespace { + +[[nodiscard]] bool events_are_semantically_equal(const Event& left, + const Event& right) +{ + return left.uid == right.uid && left.title == right.title && + left.start == right.start && left.end == right.end && + left.all_day == right.all_day && left.location == right.location && + left.description == right.description && + left.calendar_id == right.calendar_id && left.color == right.color && + left.time_basis == right.time_basis && + left.time_zone == right.time_zone && + left.recurrence == right.recurrence && + left.recurrence_exceptions == right.recurrence_exceptions; +} + +void validate_events(const std::span events, + const std::string_view collection_name) +{ + std::unordered_set uids; + uids.reserve(events.size()); + + for (const auto& event : events) { + if (event.uid.empty()) { + throw std::invalid_argument{std::string{collection_name} + + " event has an empty UID"}; + } + if (!has_valid_interval(event)) { + throw std::invalid_argument{std::string{collection_name} + + " event '" + event.uid + + "' ends before it starts"}; + } + if (!uids.emplace(event.uid).second) { + throw std::invalid_argument{std::string{collection_name} + + " contains duplicate UID '" + event.uid + + "'"}; + } + } +} + +} // namespace + +void validate_events_for_export(const std::span events) +{ + validate_events(events, "export"); +} + +ImportMergeResult merge_events_for_import(const std::span target, + const std::span source) +{ + validate_events(target, "target"); + validate_events(source, "source"); + + std::unordered_map target_by_uid; + target_by_uid.reserve(target.size()); + for (const auto& event : target) target_by_uid.emplace(event.uid, &event); + + std::size_t imported = 0; + std::size_t duplicates_skipped = 0; + for (const auto& event : source) { + const auto match = target_by_uid.find(event.uid); + if (match == target_by_uid.end()) { + ++imported; + continue; + } + if (!events_are_semantically_equal(*match->second, event)) { + throw std::invalid_argument{"import UID collision for '" + event.uid + + "' differs from the target event"}; + } + ++duplicates_skipped; + } + + ImportMergeResult result; + result.events.reserve(target.size() + imported); + result.events.insert(result.events.end(), target.begin(), target.end()); + for (const auto& event : source) { + if (!target_by_uid.contains(event.uid)) result.events.push_back(event); + } + result.imported = imported; + result.duplicates_skipped = duplicates_skipped; + return result; +} + +} // namespace nocal diff --git a/src/main.cpp b/src/main.cpp index 972b6f9..a714503 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,3 +1,4 @@ +#include "nocal/domain/calendar_transfer.hpp" #include "nocal/domain/date.hpp" #include "nocal/storage/ics_store.hpp" #include "nocal/tui/Screen.hpp" @@ -23,6 +24,8 @@ constexpr std::string_view version = "0.1.0"; struct Options { std::filesystem::path calendar_path; + std::optional import_path; + std::optional export_path; bool demo{false}; bool print{false}; bool restore_backup{false}; @@ -50,6 +53,8 @@ void print_help(std::ostream& output) " --demo add sample appointments without saving them\n" " --print print one plain-text frame and exit\n" " --restore-backup restore PATH.bak over PATH and exit\n" + " --import PATH atomically merge PATH into the calendar and exit\n" + " --export PATH atomically export to a new PATH and exit\n" " --no-color disable ANSI styling\n" " -h, --help show this help\n" " -V, --version show the version\n\n" @@ -65,7 +70,15 @@ void print_help(std::ostream& output) Options parse_options(int argc, char** argv) { - Options options{.calendar_path = default_calendar_path()}; + Options options{ + .calendar_path = default_calendar_path(), + .import_path = std::nullopt, + .export_path = std::nullopt, + .demo = false, + .print = false, + .restore_backup = false, + .no_color = false, + }; bool positional_seen = false; for (int index = 1; index < argc; ++index) { const std::string_view argument{argv[index]}; @@ -85,6 +98,17 @@ Options parse_options(int argc, char** argv) options.restore_backup = true; } else if (argument == "--no-color") { options.no_color = true; + } else if (argument == "--import" || argument == "--export") { + if (++index >= argc) { + throw std::invalid_argument(std::string{argument} + " requires a path"); + } + auto& destination = argument == "--import" + ? options.import_path : options.export_path; + if (destination) { + throw std::invalid_argument(std::string{argument} + + " may only be supplied once"); + } + destination = std::filesystem::path{argv[index]}; } else if (argument == "-c" || argument == "--calendar") { if (++index >= argc) { throw std::invalid_argument(std::string{argument} + " requires a path"); @@ -106,6 +130,20 @@ Options parse_options(int argc, char** argv) if (options.restore_backup && options.print) { throw std::invalid_argument("--restore-backup cannot be combined with --print"); } + if (options.import_path && options.export_path) { + throw std::invalid_argument("--import cannot be combined with --export"); + } + const bool transfer = options.import_path || options.export_path; + if (transfer && options.demo) { + throw std::invalid_argument("--import/--export cannot be combined with --demo"); + } + if (transfer && options.print) { + throw std::invalid_argument("--import/--export cannot be combined with --print"); + } + if (transfer && options.restore_backup) { + throw std::invalid_argument( + "--import/--export cannot be combined with --restore-backup"); + } return options; } @@ -172,6 +210,70 @@ int print_frame(std::span events, bool no_color) return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; } +void print_warnings(const nocal::storage::LoadResult& loaded, + const std::filesystem::path& path) +{ + for (const auto& warning : loaded.warnings) { + std::cerr << "nocal: " << path.string() << ": " << warning << '\n'; + } +} + +void require_transfer_safe(const nocal::storage::LoadResult& loaded, + const std::string_view role) +{ + if (!loaded.safe_to_rewrite) { + throw std::runtime_error(std::string{role} + + " contains unsupported iCalendar data"); + } +} + +int import_calendar(const std::filesystem::path& source_path, + const std::filesystem::path& target_path, + nocal::storage::LoadResult target) +{ + const auto source = nocal::storage::IcsStore::load(source_path); + if (!source.revision.existed()) { + throw std::runtime_error("import source does not exist: " + source_path.string()); + } + print_warnings(source, source_path); + require_transfer_safe(target, "target calendar"); + require_transfer_safe(source, "import source"); + auto merged = nocal::merge_events_for_import(target.events, source.events); + if (merged.imported > 0) { + (void)nocal::storage::IcsStore::save(target_path, merged.events, target.revision); + } + const auto appointment_word = merged.imported == 1 ? " appointment" : " appointments"; + const auto duplicate_suffix = merged.duplicates_skipped == 1 ? "" : "s"; + std::cout << "Imported " << merged.imported << appointment_word << " from " + << source_path.string() << " into " << target_path.string() << "; " + << merged.duplicates_skipped << " duplicate" << duplicate_suffix + << " skipped.\n"; + return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; +} + +int export_calendar(const std::filesystem::path& source_path, + const std::filesystem::path& destination_path, + const nocal::storage::LoadResult& source) +{ + if (!source.revision.existed()) { + throw std::runtime_error("export source does not exist: " + source_path.string()); + } + require_transfer_safe(source, "export source"); + nocal::validate_events_for_export(source.events); + const auto destination = nocal::storage::IcsStore::load(destination_path); + if (destination.revision.existed()) { + throw std::runtime_error("export destination already exists: " + + destination_path.string()); + } + (void)nocal::storage::IcsStore::save( + destination_path, source.events, destination.revision); + const auto appointment_word = source.events.size() == 1 + ? " appointment" : " appointments"; + std::cout << "Exported " << source.events.size() << appointment_word << " from " + << source_path.string() << " to " << destination_path.string() << ".\n"; + return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; +} + } // namespace int main(int argc, char** argv) @@ -179,9 +281,7 @@ int main(int argc, char** argv) try { const auto options = parse_options(argc, argv); auto loaded = nocal::storage::IcsStore::load(options.calendar_path); - for (const auto& warning : loaded.warnings) { - std::cerr << "nocal: " << warning << '\n'; - } + print_warnings(loaded, options.calendar_path); if (options.restore_backup) { const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path); nocal::storage::IcsStore::restore_backup( @@ -190,6 +290,13 @@ int main(int argc, char** argv) << options.calendar_path.string() << '\n'; return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; } + if (options.import_path) { + return import_calendar(*options.import_path, options.calendar_path, + std::move(loaded)); + } + if (options.export_path) { + return export_calendar(options.calendar_path, *options.export_path, loaded); + } if (options.demo) { auto samples = demo_events(); loaded.events.insert(loaded.events.end(), samples.begin(), samples.end()); diff --git a/tests/calendar_transfer_tests.cpp b/tests/calendar_transfer_tests.cpp new file mode 100644 index 0000000..4bf7a1e --- /dev/null +++ b/tests/calendar_transfer_tests.cpp @@ -0,0 +1,279 @@ +#include "nocal/domain/calendar_transfer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +void check(const bool condition, const std::string_view message) +{ + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} + +template +void check_invalid_argument(Function&& function, const std::string_view message) +{ + try { + std::forward(function)(); + check(false, message); + } catch (const std::invalid_argument&) { + } catch (...) { + check(false, message); + } +} + +[[nodiscard]] nocal::TimePoint at(const int hour, const int minute = 0) +{ + using namespace std::chrono; + return nocal::TimePoint{hours{24 * 20'000 + hour} + minutes{minute}}; +} + +[[nodiscard]] nocal::Event complete_event(std::string uid) +{ + using namespace std::chrono; + nocal::Event event; + event.uid = std::move(uid); + event.title = "Planning"; + event.start = at(9); + event.end = at(10, 30); + event.all_day = false; + event.location = "Room 3"; + event.description = "Discuss launch"; + event.calendar_id = "work"; + event.color = "blue"; + event.time_basis = nocal::TimeBasis::zoned; + event.time_zone = "Europe/London"; + nocal::RecurrenceRule recurrence; + recurrence.frequency = nocal::RecurrenceFrequency::weekly; + recurrence.interval = 2; + recurrence.count = 6; + recurrence.until = at(24); + recurrence.by_weekday = {Monday, Wednesday}; + recurrence.by_month_day = {1, -1}; + recurrence.by_month = {1, 7}; + event.recurrence = std::move(recurrence); + event.recurrence_exceptions = {at(16), at(18)}; + return event; +} + +[[nodiscard]] bool equal(const nocal::Event& left, const nocal::Event& right) +{ + return left.uid == right.uid && left.title == right.title && + left.start == right.start && left.end == right.end && + left.all_day == right.all_day && left.location == right.location && + left.description == right.description && + left.calendar_id == right.calendar_id && left.color == right.color && + left.time_basis == right.time_basis && + left.time_zone == right.time_zone && + left.recurrence == right.recurrence && + left.recurrence_exceptions == right.recurrence_exceptions; +} + +[[nodiscard]] bool equal(const std::vector& left, + const std::vector& right) +{ + if (left.size() != right.size()) return false; + for (std::size_t index = 0; index < left.size(); ++index) { + if (!equal(left[index], right[index])) return false; + } + return true; +} + +void test_export_validation() +{ + nocal::validate_events_for_export({}); + const std::vector valid{complete_event("one"), complete_event("two")}; + nocal::validate_events_for_export(valid); + + auto missing_uid = valid; + missing_uid[0].uid.clear(); + check_invalid_argument( + [&] { nocal::validate_events_for_export(missing_uid); }, + "export rejects an empty UID"); + + auto duplicate_uid = valid; + duplicate_uid[1].uid = duplicate_uid[0].uid; + check_invalid_argument( + [&] { nocal::validate_events_for_export(duplicate_uid); }, + "export rejects duplicate UIDs even when event fields differ"); + + auto backwards = valid; + backwards[0].end = backwards[0].start - std::chrono::minutes{1}; + check_invalid_argument([&] { nocal::validate_events_for_export(backwards); }, + "export rejects a backwards interval"); + + auto zero_duration = complete_event("instant"); + zero_duration.end = zero_duration.start; + nocal::validate_events_for_export(std::vector{zero_duration}); +} + +void test_merge_success_order_and_counts() +{ + auto first = complete_event("target-one"); + auto second = complete_event("target-two"); + second.title = "Second target"; + auto duplicate = second; + auto third = complete_event("source-one"); + third.title = "First import"; + auto fourth = complete_event("source-two"); + fourth.title = "Second import"; + + const std::vector target{first, second}; + const std::vector source{third, duplicate, fourth}; + const auto result = nocal::merge_events_for_import(target, source); + + check(result.events.size() == 4, "merge returns target and new source events"); + check(result.imported == 2, "merge reports the number imported"); + check(result.duplicates_skipped == 1, + "merge reports exact duplicates skipped"); + check(result.events.size() == 4 && result.events[0].uid == "target-one" && + result.events[1].uid == "target-two" && + result.events[2].uid == "source-one" && + result.events[3].uid == "source-two", + "merge preserves target order and appends source order"); + check(equal(result.events[1], duplicate), + "an all-field exact duplicate is skipped without replacing target"); +} + +void test_every_event_field_participates_in_collision_equality() +{ + using Mutation = std::pair>; + const std::vector mutations{ + {"title", [](auto& event) { event.title += " changed"; }}, + {"start", [](auto& event) { event.start += std::chrono::minutes{1}; }}, + {"end", [](auto& event) { event.end += std::chrono::minutes{1}; }}, + {"all-day", [](auto& event) { event.all_day = !event.all_day; }}, + {"location", [](auto& event) { event.location += " changed"; }}, + {"description", [](auto& event) { event.description += " changed"; }}, + {"calendar ID", [](auto& event) { event.calendar_id += "-other"; }}, + {"color", [](auto& event) { event.color += "-other"; }}, + {"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }}, + {"time zone", [](auto& event) { event.time_zone = "America/New_York"; }}, + {"recurrence", [](auto& event) { event.recurrence->interval += 1; }}, + {"recurrence exclusions", + [](auto& event) { event.recurrence_exceptions.push_back(at(19)); }}, + }; + + const std::vector target{complete_event("same-uid")}; + for (const auto& [field, mutate] : mutations) { + auto collision = target[0]; + mutate(collision); + check_invalid_argument( + [&] { + (void)nocal::merge_events_for_import( + target, std::vector{collision}); + }, + std::string{"UID collision rejects a differing "} + + std::string{field}); + } + + auto removed_recurrence = target[0]; + removed_recurrence.recurrence.reset(); + check_invalid_argument( + [&] { + (void)nocal::merge_events_for_import( + target, std::vector{removed_recurrence}); + }, + "UID collision rejects recurrence presence mismatch"); +} + +void test_merge_validates_both_collections_before_result() +{ + const auto target_event = complete_event("target"); + const auto source_event = complete_event("source"); + + auto empty_target_uid = std::vector{target_event}; + empty_target_uid[0].uid.clear(); + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import(empty_target_uid, {}); }, + "merge rejects an empty target UID"); + + const std::vector duplicate_target{target_event, target_event}; + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import(duplicate_target, {}); }, + "merge rejects duplicate target UIDs"); + + auto empty_source_uid = std::vector{source_event}; + empty_source_uid[0].uid.clear(); + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import({}, empty_source_uid); }, + "merge rejects an empty source UID"); + + const std::vector duplicate_source{source_event, source_event}; + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import({}, duplicate_source); }, + "merge rejects duplicate source UIDs"); + + auto invalid_target = std::vector{target_event}; + invalid_target[0].end = invalid_target[0].start - std::chrono::seconds{1}; + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import(invalid_target, {}); }, + "merge rejects a backwards target interval"); + + auto invalid_source = std::vector{source_event}; + invalid_source[0].end = invalid_source[0].start - std::chrono::seconds{1}; + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import({}, invalid_source); }, + "merge rejects a backwards source interval"); +} + +void test_inputs_are_not_mutated_on_failure() +{ + auto target_event = complete_event("collision"); + auto source_event = target_event; + source_event.description = "Different data"; + source_event.recurrence_exceptions.push_back(at(21)); + + std::vector target{target_event}; + std::vector source{source_event}; + const auto target_before = target; + const auto source_before = source; + + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import(target, source); }, + "a differing collision fails the whole merge"); + check(equal(target, target_before), "failed merge does not mutate target input"); + check(equal(source, source_before), "failed merge does not mutate source input"); + + source.push_back(complete_event("new-before-invalid")); + auto invalid = complete_event("invalid-after-new"); + invalid.end = invalid.start - std::chrono::hours{1}; + source.push_back(invalid); + const auto source_with_invalid_before = source; + check_invalid_argument( + [&] { (void)nocal::merge_events_for_import(target, source); }, + "source validation fails before exposing an earlier import"); + check(equal(source, source_with_invalid_before), + "validation failure after a valid source item leaves source unchanged"); +} + +} // namespace + +int main() +{ + test_export_validation(); + test_merge_success_order_and_counts(); + test_every_event_field_participates_in_collision_equality(); + test_merge_validates_both_collections_before_result(); + test_inputs_are_not_mutated_on_failure(); + + if (failures != 0) { + std::cerr << failures << " calendar transfer test(s) failed\n"; + return EXIT_FAILURE; + } + std::cout << "calendar transfer tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/cli_transfer_test.sh b/tests/cli_transfer_test.sh new file mode 100644 index 0000000..96c8580 --- /dev/null +++ b/tests/cli_transfer_test.sh @@ -0,0 +1,280 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: cli_transfer_test.sh /path/to/nocal" >&2 + exit 2 +fi + +nocal=$1 +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-transfer.XXXXXX") +trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM + +fail() +{ + echo "$1" >&2 + exit 1 +} + +expect_failure() +{ + description=$1 + shift + if "$@" >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "$description unexpectedly succeeded" + fi +} + +write_target() +{ + path=$1 + cat >"$path" <<'EOF' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nocal transfer CLI test//EN +BEGIN:VEVENT +UID:existing@nocal.test +DTSTART:20260720T090000 +DTEND:20260720T100000 +SUMMARY:Existing appointment +LOCATION:Desk +END:VEVENT +END:VCALENDAR +EOF +} + +source_calendar=$tmpdir/source.ics +cat >"$source_calendar" <<'EOF' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nocal transfer CLI test//EN +BEGIN:VEVENT +UID:existing@nocal.test +DTSTART:20260720T090000 +DTEND:20260720T100000 +SUMMARY:Existing appointment +LOCATION:Desk +END:VEVENT +BEGIN:VEVENT +UID:imported@nocal.test +DTSTART:20260721T140000Z +DTEND:20260721T150000Z +SUMMARY:Imported appointment +DESCRIPTION:Transferred deterministically +X-NOCAL-CALENDAR-ID:work +END:VEVENT +END:VCALENDAR +EOF + +# Import merges new appointments, skips exact duplicates, and preserves the +# exact pre-import bytes as the adjacent backup. +target=$tmpdir/target.ics +write_target "$target" +cp "$target" "$tmpdir/target-before.ics" +import_summary=$( + "$nocal" --import "$source_calendar" --calendar "$target" +) +printf '%s\n' "$import_summary" | grep -E 'Imported[[:space:]]+1([^0-9]|$)' >/dev/null || + fail "import summary does not report one imported appointment" +printf '%s\n' "$import_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null || + fail "import summary does not report one skipped duplicate" +cmp "$target.bak" "$tmpdir/target-before.ics" >/dev/null || + fail "successful import backup does not contain the exact prior target bytes" +grep -F 'UID:existing@nocal.test' "$target" >/dev/null +grep -F 'UID:imported@nocal.test' "$target" >/dev/null + +# A source containing only an exact duplicate must not rewrite the target or +# create a backup. +duplicate_source=$tmpdir/duplicate-source.ics +write_target "$duplicate_source" +duplicate_target=$tmpdir/duplicate-target.ics +write_target "$duplicate_target" +cp "$duplicate_target" "$tmpdir/duplicate-target-before.ics" +duplicate_summary=$( + "$nocal" --import "$duplicate_source" --calendar "$duplicate_target" +) +printf '%s\n' "$duplicate_summary" | grep -E 'Imported[[:space:]]+0([^0-9]|$)' >/dev/null || + fail "no-op import summary does not report zero imported appointments" +printf '%s\n' "$duplicate_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null || + fail "no-op import summary does not report one skipped duplicate" +cmp "$duplicate_target" "$tmpdir/duplicate-target-before.ics" >/dev/null || + fail "exact-duplicate import rewrote the target" +[ ! -e "$duplicate_target.bak" ] || + fail "exact-duplicate import created a backup despite performing no write" + +# A differing appointment with the same UID is a conflict. No target or +# backup may be produced by the failed mutation. +conflict_source=$tmpdir/conflict-source.ics +cat >"$conflict_source" <<'EOF' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nocal transfer CLI test//EN +BEGIN:VEVENT +UID:existing@nocal.test +DTSTART:20260720T090000 +DTEND:20260720T100000 +SUMMARY:Conflicting appointment +LOCATION:Desk +END:VEVENT +END:VCALENDAR +EOF +conflict_target=$tmpdir/conflict-target.ics +write_target "$conflict_target" +cp "$conflict_target" "$tmpdir/conflict-target-before.ics" +expect_failure "conflicting-UID import" \ + "$nocal" --import "$conflict_source" --calendar "$conflict_target" +cmp "$conflict_target" "$tmpdir/conflict-target-before.ics" >/dev/null || + fail "conflicting-UID import changed the target" +[ ! -e "$conflict_target.bak" ] || + fail "conflicting-UID import created a backup" + +missing_uid=$tmpdir/missing-uid.ics +cat >"$missing_uid" <<'EOF' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nocal transfer CLI test//EN +BEGIN:VEVENT +DTSTART:20260722T110000 +DTEND:20260722T120000 +SUMMARY:Missing identity +END:VEVENT +END:VCALENDAR +EOF + +duplicate_uid=$tmpdir/duplicate-uid.ics +cat >"$duplicate_uid" <<'EOF' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nocal transfer CLI test//EN +BEGIN:VEVENT +UID:duplicate@nocal.test +DTSTART:20260722T110000 +DTEND:20260722T120000 +SUMMARY:First duplicate identity +END:VEVENT +BEGIN:VEVENT +UID:duplicate@nocal.test +DTSTART:20260723T110000 +DTEND:20260723T120000 +SUMMARY:Second duplicate identity +END:VEVENT +END:VCALENDAR +EOF + +unsupported=$tmpdir/unsupported.ics +cat >"$unsupported" <<'EOF' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nocal transfer CLI test//EN +BEGIN:VEVENT +UID:unsupported@nocal.test +DTSTART:20260722T110000 +DTEND:20260722T120000 +SUMMARY:Unsupported appointment +ATTENDEE:mailto:person@example.test +END:VEVENT +END:VCALENDAR +EOF + +# Unsafe import sources are rejected before the target persistence boundary. +for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do + unsafe_name=$(basename "$unsafe_source" .ics) + unsafe_target=$tmpdir/import-$unsafe_name-target.ics + write_target "$unsafe_target" + cp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics" + expect_failure "$unsafe_name import" \ + "$nocal" --import "$unsafe_source" --calendar "$unsafe_target" + cmp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics" >/dev/null || + fail "$unsafe_name import changed the target" + [ ! -e "$unsafe_target.bak" ] || + fail "$unsafe_name import created a backup" +done + +# Identity validation applies equally to the target and leaves its exact bytes +# untouched when it fails. +duplicate_uid_target=$tmpdir/duplicate-uid-target.ics +cp "$duplicate_uid" "$duplicate_uid_target" +cp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics" +expect_failure "duplicate-UID target import" \ + "$nocal" --import "$source_calendar" --calendar "$duplicate_uid_target" +cmp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics" >/dev/null || + fail "import changed a duplicate-UID target" +[ ! -e "$duplicate_uid_target.bak" ] || + fail "import into a duplicate-UID target created a backup" + +# An unsafe target is read-only even when the source is valid. +read_only_target=$tmpdir/read-only-target.ics +cp "$unsupported" "$read_only_target" +cp "$read_only_target" "$tmpdir/read-only-target-before.ics" +expect_failure "read-only target import" \ + "$nocal" --import "$source_calendar" --calendar "$read_only_target" +cmp "$read_only_target" "$tmpdir/read-only-target-before.ics" >/dev/null || + fail "import changed a read-only target" +[ ! -e "$read_only_target.bak" ] || + fail "import into a read-only target created a backup" + +# Export creates a new parseable calendar. Re-exporting that calendar yields +# identical canonical bytes, demonstrating equivalent parsed appointments. +exported=$tmpdir/exported.ics +export_summary=$( + "$nocal" --export "$exported" --calendar "$source_calendar" +) +printf '%s\n' "$export_summary" | grep -E 'Exported[[:space:]]+2([^0-9]|$)' >/dev/null || + fail "export summary does not report two exported appointments" +[ -f "$exported" ] || fail "successful export did not create its destination" +[ ! -e "$exported.bak" ] || fail "successful new export unexpectedly created a backup" +canonical_copy=$tmpdir/exported-again.ics +"$nocal" --export "$canonical_copy" --calendar "$exported" >"$tmpdir/out" +cmp "$exported" "$canonical_copy" >/dev/null || + fail "exported calendar did not parse and round-trip equivalently" + +# Existing export destinations are never overwritten or backed up. +existing_destination=$tmpdir/existing-destination.ics +printf '%s\n' 'sentinel destination bytes' >"$existing_destination" +cp "$existing_destination" "$tmpdir/existing-destination-before" +expect_failure "export to existing destination" \ + "$nocal" --export "$existing_destination" --calendar "$source_calendar" +cmp "$existing_destination" "$tmpdir/existing-destination-before" >/dev/null || + fail "failed export changed its existing destination" +[ ! -e "$existing_destination.bak" ] || + fail "failed export created a backup for its existing destination" + +# Unsafe export sources fail without creating any destination or backup. +for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do + unsafe_name=$(basename "$unsafe_source" .ics) + unsafe_destination=$tmpdir/export-$unsafe_name-output.ics + expect_failure "$unsafe_name export" \ + "$nocal" --export "$unsafe_destination" --calendar "$unsafe_source" + [ ! -e "$unsafe_destination" ] || + fail "$unsafe_name export created an output file" + [ ! -e "$unsafe_destination.bak" ] || + fail "$unsafe_name export created an output backup" +done + +# Transfer actions are exclusive and cannot be combined with interactive, +# print, demo, or recovery actions. These parse failures must occur without a +# destination write or target backup. +option_target=$tmpdir/option-target.ics +write_target "$option_target" +cp "$option_target" "$tmpdir/option-target-before.ics" +option_export=$tmpdir/option-export.ics + +expect_failure "combined import and export" \ + "$nocal" --import "$source_calendar" --export "$option_export" \ + --calendar "$option_target" + +for conflicting_option in --demo --print --restore-backup; do + expect_failure "import with $conflicting_option" \ + "$nocal" --import "$source_calendar" "$conflicting_option" \ + --calendar "$option_target" + expect_failure "export with $conflicting_option" \ + "$nocal" --export "$option_export" "$conflicting_option" \ + --calendar "$option_target" +done + +cmp "$option_target" "$tmpdir/option-target-before.ics" >/dev/null || + fail "invalid transfer option combination changed the calendar" +[ ! -e "$option_target.bak" ] || + fail "invalid transfer option combination created a calendar backup" +[ ! -e "$option_export" ] || + fail "invalid transfer option combination created an export destination"