Scaffold calcalist: repo, toolchain, config model and doctor
Initialise the project per the approved implementation plan (M0).
- Pin the toolchain with devbox: Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0,
and radicale 3.7.8 for later integration tests.
- Add the configuration model with full referential validation, reporting
every problem in one pass rather than short-circuiting on the first.
- Add `calcalist doctor`, verifying pimsync's presence and version series,
the state directory, and the configuration.
- Define the whole CLI surface; only `doctor` acts, the rest exit 2 rather
than pretending to work.
- Rewrite SPECS.md: Rust naming conventions in place of the JS/TS style
lines, a `devbox run check` gate instead of a pre-commit hook (jj runs no
git hooks), and the sync semantics the spec previously left unstated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:10:16 +03:00
|
|
|
//! Command line surface.
|
|
|
|
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
use clap::{Parser, Subcommand};
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Parser)]
|
|
|
|
|
#[command(
|
|
|
|
|
name = "calcalist",
|
|
|
|
|
version,
|
|
|
|
|
about = "Aggregate and sync events between CalDAV, Google Calendar and iCal feeds"
|
|
|
|
|
)]
|
|
|
|
|
pub struct Cli {
|
|
|
|
|
/// Configuration file (default: $XDG_CONFIG_HOME/calcalist/calcalist.toml)
|
|
|
|
|
#[arg(long, short, global = true, value_name = "FILE")]
|
|
|
|
|
pub config: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
pub command: Command,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
pub enum Command {
|
|
|
|
|
/// Run one synchronisation cycle
|
|
|
|
|
Sync {
|
|
|
|
|
/// Report what would change without writing anything
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
dry_run: bool,
|
|
|
|
|
/// Proceed even when the mass-deletion guard trips
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
force: bool,
|
|
|
|
|
},
|
|
|
|
|
/// Show endpoints, aggregates and the last sync result
|
|
|
|
|
Status,
|
Finish M2: run lock, systemd units, discover; drop the web UI
Designing the configuration UI in full made the case against building it. Its
audience would be people who find TOML hard, but with bring-your-own OAuth
client settled, every user must first create a Google Cloud project, configure
a consent screen and put a secret in a keyring — a far higher bar than editing
thirty lines of config. Anyone who clears it can edit the file; anyone who
cannot never reaches the file. Against that stood three dependencies, five
modules, an auth.rs refactor and a security surface guarding something that
reads the config and touches the keyring. `doctor` and `status` had already
absorbed most of what it was for. The reasoning is recorded in TODO.md and
SPECS.md rather than left as an apparent oversight.
The run lock is not a UI feature and closes a gap that already existed: nothing
stopped a timer firing into a hand-run cycle, and two cycles interleaving
writes over the same vdirs is what the design otherwise avoids. flock is used
rather than a pid file because the kernel releases it however the process ends,
so a crash cannot leave a lock to clear by hand — which also means a lock we
failed to take is held by a live process, so the pid in it is worth reporting.
The one idea worth keeping from the UI design was collection discovery, which
needed no web layer. `calcalist discover` prints a ready-to-paste endpoint
block per calendar a server offers, removing the most error-prone field in the
config. pimsync's discovery output is undocumented, so the format was
established against a real server first. Two things it teaches: everything
arrives on stdout including failures, and a pair has two storages, so pimsync
reports the scratch vdir's contents too — parsing anchors on the heading naming
the server, or a probe directory's leftovers would be offered as the user's
calendars.
Verified against Posteo as well as Radicale: all four calendars found, the
first matching the URL already configured.
Also fixes a real defect in the test harness rather than its symptom. Ports were
chosen by binding one and letting go, so two tests could pick the same number —
and the loser's readiness check then succeeded against the winner's server,
silently sharing it. Startup now confirms the child we spawned is the one alive,
retries on another port if not, and waits for a real HTTP response rather than
an open socket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:17:34 +03:00
|
|
|
/// List the calendars a CalDAV server offers, as endpoint blocks to paste
|
|
|
|
|
Discover {
|
|
|
|
|
/// The account or principal URL to enumerate, not a single calendar
|
|
|
|
|
url: String,
|
|
|
|
|
/// Username to authenticate as
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
username: String,
|
|
|
|
|
/// Command printing the password, exactly as it would appear in the
|
|
|
|
|
/// configuration. Secrets are never passed as arguments.
|
|
|
|
|
#[arg(long, value_name = "COMMAND")]
|
|
|
|
|
secret_command: Option<String>,
|
Scaffold calcalist: repo, toolchain, config model and doctor
Initialise the project per the approved implementation plan (M0).
- Pin the toolchain with devbox: Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0,
and radicale 3.7.8 for later integration tests.
- Add the configuration model with full referential validation, reporting
every problem in one pass rather than short-circuiting on the first.
- Add `calcalist doctor`, verifying pimsync's presence and version series,
the state directory, and the configuration.
- Define the whole CLI surface; only `doctor` acts, the rest exit 2 rather
than pretending to work.
- Rewrite SPECS.md: Rust naming conventions in place of the JS/TS style
lines, a `devbox run check` gate instead of a pre-commit hook (jj runs no
git hooks), and the sync semantics the spec previously left unstated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:10:16 +03:00
|
|
|
},
|
|
|
|
|
/// Google account operations
|
|
|
|
|
Google {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: GoogleCommand,
|
|
|
|
|
},
|
|
|
|
|
/// Aggregate maintenance
|
|
|
|
|
Aggregate {
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: AggregateCommand,
|
|
|
|
|
},
|
Close the remaining M1 gaps
Eight items were still open at the end of M1: two integration tests that had
only been run by hand, and six known gaps.
Recurrence overrides now reach Google. Google addresses an exception through
the series rather than as an event of its own, so the master is sent first and
each override is then matched to its instance by original start time and
patched. Matching needs the two sides' spellings reduced to one key: iCalendar
writes a zoned local time, Google an absolute offset. An override matching no
occurrence is counted rather than forced — that means a stale RECURRENCE-ID
left behind by an edited RRULE, and inventing an event for it would put
something in the calendar the series does not contain.
Reading one component apart from another needed a view `properties` cannot
give: it flattens every VEVENT together, which is right for the UID a series
shares and wrong for an override, whose SUMMARY and the master's are then
indistinguishable. `Calendar::events` splits them.
A TZID now travels with the VTIMEZONE that defines it, derived from the zone's
own transition table as the yearly rule it implies. This changes the content
hash of every zoned recurring event, so the first cycle after this re-pushes
them.
A Google authorisation is filed under the account it was granted for rather
than the endpoint that asked for it, so two endpoints on one account no longer
need a login each. The account is read from the primary calendar's id, which
needs no scope beyond the calendar one already granted. Authorisations written
by the previous scheme are still honoured, and move across at the next login.
An unreachable Google endpoint no longer ends the cycle — one lapsed token used
to stop the CalDAV side too. It is named, only the aggregates depending on it
stand down, and the run exits non-zero so a partial cycle cannot pass for
success. The CalDAV leg cannot be narrowed the same way: pimsync is one process
covering every pair, so a failure does not say which pair it belongs to.
`--dry-run` now pulls for real, into a throwaway copy of the local mirrors and
through a pimsync configuration that only ever reads from a server. What it
reports is measured against the calendars as they are now rather than against
whatever the last real cycle left behind.
`calcalist prune` reports local mirrors of endpoints the configuration no
longer names, and removes them under --force.
The integration tests run against a real Radicale server and a real iCal feed:
convergence and idempotence, the dry run, prune, and the scheduling rule
asserted on the bytes that actually reached the server. The plan asked for an
SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a
quiet SMTP port would have proved nothing about the transform.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:49:38 +03:00
|
|
|
/// Remove local mirrors of endpoints the configuration no longer names
|
|
|
|
|
Prune {
|
|
|
|
|
/// Delete them; without this the command only reports what it found
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
force: bool,
|
|
|
|
|
},
|
Scaffold calcalist: repo, toolchain, config model and doctor
Initialise the project per the approved implementation plan (M0).
- Pin the toolchain with devbox: Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0,
and radicale 3.7.8 for later integration tests.
- Add the configuration model with full referential validation, reporting
every problem in one pass rather than short-circuiting on the first.
- Add `calcalist doctor`, verifying pimsync's presence and version series,
the state directory, and the configuration.
- Define the whole CLI surface; only `doctor` acts, the rest exit 2 rather
than pretending to work.
- Rewrite SPECS.md: Rust naming conventions in place of the JS/TS style
lines, a `devbox run check` gate instead of a pre-commit hook (jj runs no
git hooks), and the sync semantics the spec previously left unstated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:10:16 +03:00
|
|
|
/// Check that the environment and configuration are usable
|
|
|
|
|
Doctor,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
pub enum GoogleCommand {
|
|
|
|
|
/// Authorise calcalist against a Google account
|
|
|
|
|
Login {
|
|
|
|
|
/// Endpoint id to authorise
|
|
|
|
|
endpoint: String,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
pub enum AggregateCommand {
|
|
|
|
|
/// Move an aggregate to a different target endpoint
|
|
|
|
|
Retarget {
|
|
|
|
|
/// Aggregate id
|
|
|
|
|
id: String,
|
|
|
|
|
/// Endpoint id to publish the aggregate to from now on
|
|
|
|
|
#[arg(long = "to", value_name = "ENDPOINT")]
|
|
|
|
|
to: String,
|
|
|
|
|
/// Leave mirrored events on the previous target (default)
|
|
|
|
|
#[arg(long, conflicts_with = "purge_old")]
|
|
|
|
|
keep_old: bool,
|
|
|
|
|
/// Delete calcalist's mirrored events from the previous target
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
purge_old: bool,
|
|
|
|
|
},
|
|
|
|
|
}
|