#!/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"