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.
This commit is contained in:
2026-07-18 09:32:54 +01:00
parent 8b6cf2e877
commit b524e6e33d
11 changed files with 867 additions and 8 deletions

View File

@@ -6,8 +6,9 @@ inherits its colors from your terminal theme.
This repository currently contains the first local-calendar vertical slice: This repository currently contains the first local-calendar vertical slice:
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms, date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
a responsive ANSI terminal UI with month and agenda views, tests, and Linux a responsive ANSI terminal UI with month and agenda views, explicit safe
launch integration. See [the product specification](docs/PRODUCT.md), calendar import/export, tests, and Linux launch integration. See
[the product specification](docs/PRODUCT.md),
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md). [architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
## Controls ## 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 positional argument. `--demo` adds a few unsaved sample appointments and
`--print` emits a non-interactive frame. `--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: Nocal expands and safely round-trips a focused iCalendar recurrence subset:
daily, weekly, monthly, and yearly rules with interval, count or until, common daily, weekly, monthly, and yearly rules with interval, count or until, common
weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA

View File

@@ -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 rolls it back if persistence fails. Its bounded undo/redo history contains only
mutations that crossed the persistence boundary successfully. 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 The current writer deliberately refuses to mutate an existing file when the
loader encounters information it cannot round-trip, including recurrence loader encounters information it cannot round-trip, including recurrence
overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown

View File

@@ -81,7 +81,7 @@ The full terminal is treated as a responsive canvas:
The first usable foundation supports day and appointment navigation, a The first usable foundation supports day and appointment navigation, a
full-frame reader, a read-only 42-day agenda, validated add/edit/delete forms, 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 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 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 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 the month workflow. The next local-data work adds advanced recurrence overrides
and rule editing. 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 ## Accessibility
- Information is never encoded by color alone. - Information is never encoded by color alone.

View File

@@ -12,13 +12,14 @@
- Occurrence-aware appointment search with exact result-to-grid navigation - Occurrence-aware appointment search with exact result-to-grid navigation
- Session calendar toggles shared by month rendering, focus, and search - Session calendar toggles shared by month rendering, focus, and search
- Read-only 42-day agenda with bounded recurrence and exact return navigation - 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 - Meson build, Nix development shell, desktop entry, and Hyprland launcher
- Seeded sample data when explicitly requested, never silent data mutation - Seeded sample data when explicitly requested, never silent data mutation
## 0.1 — complete local calendar ## 0.1 — complete local calendar
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support - 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 - Screen-reader-friendly linear view and full Unicode display-width handling
## 0.2 — durable cache and provider boundary ## 0.2 — durable cache and provider boundary

View File

@@ -0,0 +1,28 @@
#pragma once
#include "nocal/domain/event.hpp"
#include <cstddef>
#include <span>
#include <vector>
namespace nocal {
struct ImportMergeResult {
std::vector<Event> 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<const Event> 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<const Event> target, std::span<const Event> source);
} // namespace nocal

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "nocal/domain/calendar_transfer.hpp"
#include "nocal/domain/date.hpp" #include "nocal/domain/date.hpp"
#include "nocal/domain/event.hpp" #include "nocal/domain/event.hpp"
#include "nocal/domain/event_query.hpp" #include "nocal/domain/event_query.hpp"

View File

@@ -13,6 +13,7 @@ project(
inc = include_directories('include') inc = include_directories('include')
nocal_sources = files( nocal_sources = files(
'src/domain/calendar_transfer.cpp',
'src/domain/date.cpp', 'src/domain/date.cpp',
'src/domain/event_query.cpp', 'src/domain/event_query.cpp',
'src/domain/month_layout.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', domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
include_directories: inc, link_with: nocal_lib) include_directories: inc, link_with: nocal_lib)
test('domain', domain_tests) 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', ics_tests = executable('ics-tests', 'tests/ics_tests.cpp',
include_directories: inc, link_with: nocal_lib) include_directories: inc, link_with: nocal_lib)
test('ics-storage', ics_tests) test('ics-storage', ics_tests)
@@ -46,6 +51,8 @@ test('editor', editor_tests)
shell = find_program('sh') shell = find_program('sh')
test('cli-recovery', shell, test('cli-recovery', shell,
args: [files('tests/cli_recovery_test.sh'), nocal_executable]) 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_data('scripts/nocal-hyprland', install_dir: get_option('bindir'),
install_mode: 'rwxr-xr-x') install_mode: 'rwxr-xr-x')

View File

@@ -0,0 +1,93 @@
#include "nocal/domain/calendar_transfer.hpp"
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
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<const Event> events,
const std::string_view collection_name)
{
std::unordered_set<std::string_view> 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<const Event> events)
{
validate_events(events, "export");
}
ImportMergeResult merge_events_for_import(const std::span<const Event> target,
const std::span<const Event> source)
{
validate_events(target, "target");
validate_events(source, "source");
std::unordered_map<std::string_view, const Event*> 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

View File

@@ -1,3 +1,4 @@
#include "nocal/domain/calendar_transfer.hpp"
#include "nocal/domain/date.hpp" #include "nocal/domain/date.hpp"
#include "nocal/storage/ics_store.hpp" #include "nocal/storage/ics_store.hpp"
#include "nocal/tui/Screen.hpp" #include "nocal/tui/Screen.hpp"
@@ -23,6 +24,8 @@ constexpr std::string_view version = "0.1.0";
struct Options { struct Options {
std::filesystem::path calendar_path; std::filesystem::path calendar_path;
std::optional<std::filesystem::path> import_path;
std::optional<std::filesystem::path> export_path;
bool demo{false}; bool demo{false};
bool print{false}; bool print{false};
bool restore_backup{false}; bool restore_backup{false};
@@ -50,6 +53,8 @@ void print_help(std::ostream& output)
" --demo add sample appointments without saving them\n" " --demo add sample appointments without saving them\n"
" --print print one plain-text frame and exit\n" " --print print one plain-text frame and exit\n"
" --restore-backup restore PATH.bak over PATH 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" " --no-color disable ANSI styling\n"
" -h, --help show this help\n" " -h, --help show this help\n"
" -V, --version show the version\n\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 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; bool positional_seen = false;
for (int index = 1; index < argc; ++index) { for (int index = 1; index < argc; ++index) {
const std::string_view argument{argv[index]}; const std::string_view argument{argv[index]};
@@ -85,6 +98,17 @@ Options parse_options(int argc, char** argv)
options.restore_backup = true; options.restore_backup = true;
} else if (argument == "--no-color") { } else if (argument == "--no-color") {
options.no_color = true; 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") { } else if (argument == "-c" || argument == "--calendar") {
if (++index >= argc) { if (++index >= argc) {
throw std::invalid_argument(std::string{argument} + " requires a path"); 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) { if (options.restore_backup && options.print) {
throw std::invalid_argument("--restore-backup cannot be combined with --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; return options;
} }
@@ -172,6 +210,70 @@ int print_frame(std::span<const nocal::Event> events, bool no_color)
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; 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 } // namespace
int main(int argc, char** argv) int main(int argc, char** argv)
@@ -179,9 +281,7 @@ int main(int argc, char** argv)
try { try {
const auto options = parse_options(argc, argv); const auto options = parse_options(argc, argv);
auto loaded = nocal::storage::IcsStore::load(options.calendar_path); auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
for (const auto& warning : loaded.warnings) { print_warnings(loaded, options.calendar_path);
std::cerr << "nocal: " << warning << '\n';
}
if (options.restore_backup) { if (options.restore_backup) {
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path); const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
nocal::storage::IcsStore::restore_backup( nocal::storage::IcsStore::restore_backup(
@@ -190,6 +290,13 @@ int main(int argc, char** argv)
<< options.calendar_path.string() << '\n'; << options.calendar_path.string() << '\n';
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; 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) { if (options.demo) {
auto samples = demo_events(); auto samples = demo_events();
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end()); loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());

View File

@@ -0,0 +1,279 @@
#include "nocal/domain/calendar_transfer.hpp"
#include <chrono>
#include <cstdlib>
#include <functional>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
template <class Function>
void check_invalid_argument(Function&& function, const std::string_view message)
{
try {
std::forward<Function>(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<nocal::Event>& left,
const std::vector<nocal::Event>& 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<std::string_view, std::function<void(nocal::Event&)>>;
const std::vector<Mutation> 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<nocal::Event>{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<nocal::Event>{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;
}

280
tests/cli_transfer_test.sh Normal file
View File

@@ -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"