CalCalist/CLAUDE.md
randogoth 622a71dcd6 Sync WebCal and CalDAV directly; add a skip_past cutoff
WebCal is calcalist's own (src/webcal.rs), with Calendar::split_by_uid to turn
one feed document into one file per UID. Shared crate::http and crate::secret
came out of google/ so every leg uses them.

CalDAV follows, behind a per-endpoint 'native' flag so it can run beside
pimsync one collection at a time: src/caldav/client.rs speaks PROPFIND,
sync-collection, calendar-multiget and conditional PUT/DELETE, and
src/caldav/mod.rs is the pull/push pair. Radicale answers an initial
sync-collection with no members, so a cursor is only stored alongside a
complete read. doctor reports what a server advertises.

skip_past (src/horizon.rs) takes a date, not a flag: a rolling 'before now'
would retire events from the aggregate as they aged, deleting yesterday's
events on every cycle. Pinned to a date, what is mirrored stays mirrored. On
the real configuration it leaves 2600 of 3282 events behind. The filter sits
above the prior-links lookup in mirror_source, which is what makes it safe:
from there DeleteSource is unreachable, so excluding an event can never be
read as the user having deleted its mirror.

pimsync: CWD pinned so vstorage's relative unlink cannot reach the user's
files; the dry-run workspace is removed by Drop rather than by a line every
'?' stepped over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:41:32 +03:00

4.9 KiB

calcalist

Aggregates events between CalDAV, Google Calendar and iCal feeds. README.md is the user-facing guide; the design rationale lives in the module doc comments, beside the code it governs.

These conventions were SPECS.md's, which has been retired now the roadmap is finished. Git history holds it, and TODO.md, if the record is ever wanted.

Development

Use devbox for dependency management and scripts. Use jj for version control (colocated with git).

  • devbox run fmt — format.
  • devbox run check — format check, clippy with warnings denied, and tests. This is the gate; jj has no commit step to hang a hook off, since the working copy is itself a commit.
  • devbox run test — tests only.

Keep /target in .gitignore: jj snapshots the working copy on every command, with no staging step.

The integration tests in tests/ need radicale and pimsync, both of which devbox provides. Outside that shell they report what is missing and pass.

Coding style

  • Simple, readable, idiomatic.
  • Explicit types for public APIs and important fields.
  • Prefer immutable data structures.
  • No global mutable state.
  • No speculative features or dependencies.
  • Keep functions short and single-purpose; extract helpers to avoid nesting.
  • File and module naming: snake_case.rs. Types and traits: PascalCase. Functions, variables and fields: snake_case. Constants: SCREAMING_SNAKE_CASE.
  • Run devbox run check before describing a change.
  • Comment only when intent is not obvious from the code.
  • Prefer composition and traits over deep type hierarchies.

Decisions already taken

Settled after real design work. Reopen only with new information, not from first principles.

No configuration web interface. Designed in full, then dropped. Its audience would be people who find TOML hard — but every user must first create a Google Cloud project, configure a consent screen and put a secret in a keyring, which is a far higher bar than editing the config. Anyone who clears it can edit the file; anyone who cannot never reaches the file. doctor, status and discover cover what the interface was actually for.

Bring-your-own OAuth client, not a shipped verified one. Verification for the Calendar scope needs a domain you own — a github.io address is rejected — and Calendar API quota is per Cloud project, with charges announced for exceeding it. A shipped client would pool every user's syncing into the publisher's project, and their bill. Shipping an unverified shared client is worse still: the 100-new-user cap is permanent for the project's lifetime.

pimsync cannot reach Google, and never will. It has no REST storage, and its config exposes only HTTP Basic auth, which Google's CalDAV endpoint rejected in March 2025. The Google leg is calcalist's own in every design.

The config file is never machine-rewritten. It is the portable, hand-editable artefact. Anything that writes it must preserve comments, ordering and omitted defaults — toml::to_string on the Serialize derive does none of those things.

WebCal and Google are calcalist's own; CalDAV is being taken back too. A feed is one iCalendar document fetched over HTTP and read-only by protocol, so routing it through a generated config, a subprocess and a status database bought nothing — it cost three minutes a cycle in observed stalls. CalDAV has followed, behind a per-endpoint native flag so the two run side by side until the native client has earned the default. The argument is not that pimsync is bad but that calcalist was paying for generality it never used: one server, against 528 lines of adapter, a 59.6 MB flake closure, an undocumented discover format that is scraped, a version range to maintain, and a vstorage bug that unlinked order relative to the working directory.

Radicale is the CalDAV target; other servers are untested, not unsupported. Narrowing is what makes owning the protocol reasonable: the integration tests already run against Radicale, so every assumption is checked on every devbox run check, and when the RFC is ambiguous its source settles it. The discipline that keeps this honest is that server-specific accommodations stay named, never absorbed into the general path — Radicale answers an initial sync-collection with a token and no members, which is written as "always load in full first, then go incremental", correct anywhere, rather than as a quirk worked around in place. doctor reports what a server advertises so that pointing calcalist elsewhere fails legibly.

Known gap

A failing pimsync sync stops every CalDAV endpoint pimsync still owns. It is one process covering every pair, and a failure does not say which pair it belongs to, so the endpoints cannot be told apart the way the Google ones are. native = true takes an endpoint out of its reach entirely, which is the way out rather than a narrowing of pimsync's own reporting.