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>
This commit is contained in:
commit
2bc93262f8
12 changed files with 1674 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
/.devbox
|
||||||
|
/result
|
||||||
315
Cargo.lock
generated
Normal file
315
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "calcalist"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "calcalist"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.97"
|
||||||
|
description = "Aggregate and sync events between CalDAV, Google Calendar and iCal feeds"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
thiserror = "2.0"
|
||||||
|
toml = "1.1"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
56
SPECS.md
Normal file
56
SPECS.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Calcalist
|
||||||
|
|
||||||
|
## Idea
|
||||||
|
|
||||||
|
Small Rust based commandline tool with utility systemd service file that can aggregate and sync events between CalDAV/Google Calendar/iCal. Users who have a Google Calendar can create a new calendar that syncs events from several CalDAV calendars. CalDAV users can sync several Google Calendars into one calendar. This should be doable both ways, so the aggregating calendar needs to be able to distinguish where its events came from and also have a way to create new events that get synced to the correct source. In addition iCal feeds can also be aggregated but they remain one way only.
|
||||||
|
|
||||||
|
Configuration via a simple web interface. Configuration is saved in a portable toml file that can be easily migrated.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Every endpoint — sources and aggregate targets alike — is mirrored to a local vdir, so the aggregation engine works purely on local files.
|
||||||
|
|
||||||
|
- `pimsync` is driven as a one-shot subprocess for the CalDAV and WebCal legs. Its daemon mode is unusable here: it would race the reconciler over the same vdir files, so calcalist owns scheduling.
|
||||||
|
- The Google leg is calcalist's own, via the Calendar REST API. pimsync cannot reach Google — it has no REST storage, and its config exposes only HTTP Basic auth, which Google's CalDAV endpoint has rejected since 2025-03-14.
|
||||||
|
- Sync state (event mappings, hashes, sync tokens) lives in a JSON sidecar under `$XDG_STATE_HOME/calcalist/`, never in the portable config.
|
||||||
|
- Secrets are never stored in the config either. Credentials come from `*_command` fields that are executed to fetch them.
|
||||||
|
|
||||||
|
## Sync semantics
|
||||||
|
|
||||||
|
| Question | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Provenance | Aggregate UIDs derived as `blake3(aggregate_id, source_id, source_uid)`; the state file is a cache, not a single point of failure |
|
||||||
|
| Routing new events | To the aggregate's configured `default_sink`; refused if none is set |
|
||||||
|
| Conflicts | Source wins — the origin calendar is authoritative |
|
||||||
|
| Deletion | Propagates to the source, guarded by a mass-deletion threshold |
|
||||||
|
| Attendees (mirroring) | Kept verbatim on a Google target; demoted to inert data on a CalDAV target |
|
||||||
|
| Attendees (routing) | Preserved — the sink server sends real invitations, which is intended |
|
||||||
|
| Alarms | Always preserved, with no option to strip them |
|
||||||
|
| Retargeting | `sync` refuses on target drift; `aggregate retarget` performs it deliberately |
|
||||||
|
|
||||||
|
The governing rule behind the attendee handling: **writes to an aggregate must never emit scheduling mail; writes to a source schedule normally.** Google can be told not to notify, so attendees survive intact there. CalDAV offers no portable way to suppress RFC 6638 scheduling, so inertness is achieved structurally by dropping the live properties instead.
|
||||||
|
|
||||||
|
Alarms are never stripped because the whole point of the tool is that a user subscribes to one calendar rather than several — so the duplicate-notification problem that stripping would guard against does not arise, while stripping would silently destroy every reminder in the one calendar the user actually watches.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
24
devbox.json
Normal file
24
devbox.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json",
|
||||||
|
"packages": [
|
||||||
|
"rustc@1.97.1",
|
||||||
|
"cargo@1.97.1",
|
||||||
|
"rustfmt@1.97.1",
|
||||||
|
"clippy@1.97.1",
|
||||||
|
"pimsync@0.5.11",
|
||||||
|
"jujutsu@0.44.0",
|
||||||
|
"radicale@3.7.8"
|
||||||
|
],
|
||||||
|
"shell": {
|
||||||
|
"scripts": {
|
||||||
|
"fmt": "cargo fmt --all",
|
||||||
|
"check": [
|
||||||
|
"cargo fmt --all -- --check",
|
||||||
|
"cargo clippy --all-targets -- -D warnings",
|
||||||
|
"cargo test"
|
||||||
|
],
|
||||||
|
"test": "cargo test",
|
||||||
|
"build": "cargo build --release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
323
devbox.lock
Normal file
323
devbox.lock
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
{
|
||||||
|
"lockfile_version": "1",
|
||||||
|
"packages": {
|
||||||
|
"cargo@1.97.1": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.97.1",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clippy@1.97.1": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#clippy",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.97.1",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/v3i30k0c7qw3imdbq491328r4a6qry1m-clippy-1.97.1",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/v3i30k0c7qw3imdbq491328r4a6qry1m-clippy-1.97.1"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/y6v5c5bb5drnvyf3jgm4qkkyihnfjwhj-clippy-1.97.1",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "debug",
|
||||||
|
"path": "/nix/store/3mbk6zr9jqgzv3zfz3x3mirkf9894b5k-clippy-1.97.1-debug"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/y6v5c5bb5drnvyf3jgm4qkkyihnfjwhj-clippy-1.97.1"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/n4c4rzywa0wsfy239bqkgsx8lfhsfmwx-clippy-1.97.1",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "debug",
|
||||||
|
"path": "/nix/store/nafib7p33g095kh5s8y790wdcrv7mp9l-clippy-1.97.1-debug"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/n4c4rzywa0wsfy239bqkgsx8lfhsfmwx-clippy-1.97.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"github:NixOS/nixpkgs/nixpkgs-unstable": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410?lastModified=1787814960&narHash=sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ%3D"
|
||||||
|
},
|
||||||
|
"jujutsu@0.44.0": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#jujutsu",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "0.44.0",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/lssnj3zacjjglpsbxifgsap602h2yssq-jujutsu-0.44.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/lssnj3zacjjglpsbxifgsap602h2yssq-jujutsu-0.44.0"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/4f3hrr1aik4hazqlhxadva3j36z75j8n-jujutsu-0.44.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/4f3hrr1aik4hazqlhxadva3j36z75j8n-jujutsu-0.44.0"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/vzrnnii3369cn2a2bgdlkx9gh6m297fj-jujutsu-0.44.0",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/vzrnnii3369cn2a2bgdlkx9gh6m297fj-jujutsu-0.44.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pimsync@0.5.11": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#pimsync",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "0.5.11",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/5bd6gdvkr397jx0f3hp2832wh0gl661r-pimsync-0.5.11",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/5bd6gdvkr397jx0f3hp2832wh0gl661r-pimsync-0.5.11"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/xdrn3h9qch5mkvmyw4r7majmdcndvffv-pimsync-0.5.11",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/xdrn3h9qch5mkvmyw4r7majmdcndvffv-pimsync-0.5.11"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/yivg20si8a9jlqfjzwi27ffx92c14dwh-pimsync-0.5.11",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/yivg20si8a9jlqfjzwi27ffx92c14dwh-pimsync-0.5.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"radicale@3.7.8": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#radicale",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "3.7.8",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/3bqnzzl0l5j07lsxqxv3xcd520l2ch4c-radicale-3.7.8",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dist",
|
||||||
|
"path": "/nix/store/mza9d1vwh92zpfhzsnhk5bakl2cmwh2i-radicale-3.7.8-dist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/3bqnzzl0l5j07lsxqxv3xcd520l2ch4c-radicale-3.7.8"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/b0gcy8p6svf37sij6671rlf7m1bqj0lm-radicale-3.7.8",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dist",
|
||||||
|
"path": "/nix/store/50gjl78kj1vz7bipwrvks504hkzczpl0-radicale-3.7.8-dist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/b0gcy8p6svf37sij6671rlf7m1bqj0lm-radicale-3.7.8"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/fmyar57gsfpxhisv44ghfic3mv89azrb-radicale-3.7.8",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dist",
|
||||||
|
"path": "/nix/store/warszqg8vpvr09n27qwg7qafyj8jb5l3-radicale-3.7.8-dist"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/fmyar57gsfpxhisv44ghfic3mv89azrb-radicale-3.7.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rustc@1.97.1": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"plugin_version": "0.0.1",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustc",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.97.1",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "man",
|
||||||
|
"path": "/nix/store/grw7ifdixkdr4ya8bw5x0psjyfr5azva-rustc-wrapper-1.97.1-man",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doc",
|
||||||
|
"path": "/nix/store/8qkha38sic70l51g4l7dmjrxnzqjrr6w-rustc-wrapper-1.97.1-doc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "man",
|
||||||
|
"path": "/nix/store/hn340n5pi9p30j4bcxgzqgq8lmclg1by-rustc-wrapper-1.97.1-man",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doc",
|
||||||
|
"path": "/nix/store/avbwjkw896rb3nl28kh8qv6kxyq5b9v2-rustc-wrapper-1.97.1-doc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "man",
|
||||||
|
"path": "/nix/store/an2hvf5i7mq52mqdad9rad8mxkpksmq2-rustc-wrapper-1.97.1-man",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doc",
|
||||||
|
"path": "/nix/store/8svfp79q0gx10gcrsyxf1pw04q8jf0lp-rustc-wrapper-1.97.1-doc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rustfmt@1.97.1": {
|
||||||
|
"last_modified": "2026-08-27T07:16:00Z",
|
||||||
|
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustfmt",
|
||||||
|
"source": "devbox-search",
|
||||||
|
"version": "1.97.1",
|
||||||
|
"systems": {
|
||||||
|
"aarch64-darwin": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1"
|
||||||
|
},
|
||||||
|
"aarch64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1"
|
||||||
|
},
|
||||||
|
"x86_64-linux": {
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "out",
|
||||||
|
"path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/cli.rs
Normal file
79
src/cli.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
//! 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,
|
||||||
|
/// Serve the configuration web interface on localhost
|
||||||
|
Serve {
|
||||||
|
#[arg(long, default_value_t = 8723)]
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
/// Google account operations
|
||||||
|
Google {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: GoogleCommand,
|
||||||
|
},
|
||||||
|
/// Aggregate maintenance
|
||||||
|
Aggregate {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: AggregateCommand,
|
||||||
|
},
|
||||||
|
/// 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
506
src/config.rs
Normal file
506
src/config.rs
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
//! The portable TOML configuration: endpoints, and the aggregates composed from them.
|
||||||
|
//!
|
||||||
|
//! This file is meant to be copied between machines, so it holds no secrets and no
|
||||||
|
//! sync state. Credentials are fetched by running the `*_command` fields.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::paths::{self, PathError};
|
||||||
|
|
||||||
|
/// Highest `version` this build understands.
|
||||||
|
pub const SUPPORTED_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub version: u32,
|
||||||
|
#[serde(default, rename = "endpoint")]
|
||||||
|
pub endpoints: Vec<Endpoint>,
|
||||||
|
#[serde(default, rename = "aggregate")]
|
||||||
|
pub aggregates: Vec<Aggregate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Endpoint {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub kind: EndpointKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum EndpointKind {
|
||||||
|
Caldav {
|
||||||
|
url: String,
|
||||||
|
username: String,
|
||||||
|
secret_command: Option<String>,
|
||||||
|
},
|
||||||
|
Google {
|
||||||
|
calendar_id: String,
|
||||||
|
client_id: String,
|
||||||
|
client_secret_command: Option<String>,
|
||||||
|
},
|
||||||
|
Webcal {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EndpointKind {
|
||||||
|
/// iCal feeds are read-only by protocol; everything else can be written to.
|
||||||
|
pub fn is_writable(&self) -> bool {
|
||||||
|
!matches!(self, Self::Webcal { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Aggregate {
|
||||||
|
pub id: String,
|
||||||
|
/// Endpoint id the aggregated calendar is published to.
|
||||||
|
pub target: String,
|
||||||
|
/// Endpoint ids mirrored into the aggregate.
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
/// Where events created directly in the aggregate are routed. Without one,
|
||||||
|
/// routing is refused rather than guessed.
|
||||||
|
pub default_sink: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub conflict: ConflictPolicy,
|
||||||
|
#[serde(default = "enabled")]
|
||||||
|
pub propagate_deletes: bool,
|
||||||
|
#[serde(default = "default_max_delete_fraction")]
|
||||||
|
pub max_delete_fraction: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ConflictPolicy {
|
||||||
|
/// The origin calendar is authoritative; the aggregate copy is overwritten.
|
||||||
|
#[default]
|
||||||
|
SourceWins,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_delete_fraction() -> f64 {
|
||||||
|
0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("could not read {path}: {source}")]
|
||||||
|
Read {
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("could not parse {path}: {source}")]
|
||||||
|
Parse {
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
source: toml::de::Error,
|
||||||
|
},
|
||||||
|
#[error(transparent)]
|
||||||
|
Path(#[from] PathError),
|
||||||
|
#[error(
|
||||||
|
"configuration version {found} is not supported (this build understands {SUPPORTED_VERSION})"
|
||||||
|
)]
|
||||||
|
UnsupportedVersion { found: u32 },
|
||||||
|
#[error("configuration is invalid")]
|
||||||
|
Invalid(Vec<Problem>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single validation failure. Collected rather than short-circuited, so one run
|
||||||
|
/// reports every problem in the file.
|
||||||
|
#[derive(Debug, Error, PartialEq)]
|
||||||
|
pub enum Problem {
|
||||||
|
#[error("duplicate endpoint id `{0}`")]
|
||||||
|
DuplicateEndpoint(String),
|
||||||
|
#[error("duplicate aggregate id `{0}`")]
|
||||||
|
DuplicateAggregate(String),
|
||||||
|
#[error("aggregate `{aggregate}` refers to unknown endpoint `{endpoint}`")]
|
||||||
|
UnknownEndpoint { aggregate: String, endpoint: String },
|
||||||
|
#[error("aggregate `{aggregate}` lists no sources")]
|
||||||
|
NoSources { aggregate: String },
|
||||||
|
#[error("aggregate `{aggregate}` lists source `{endpoint}` more than once")]
|
||||||
|
DuplicateSource { aggregate: String, endpoint: String },
|
||||||
|
#[error("aggregate `{aggregate}` targets `{endpoint}`, which is read-only")]
|
||||||
|
TargetNotWritable { aggregate: String, endpoint: String },
|
||||||
|
#[error(
|
||||||
|
"aggregate `{aggregate}` has `{endpoint}` as both target and source, which would sync it into itself"
|
||||||
|
)]
|
||||||
|
TargetIsSource { aggregate: String, endpoint: String },
|
||||||
|
#[error(
|
||||||
|
"aggregate `{aggregate}` has default_sink `{endpoint}`, which is not one of its sources"
|
||||||
|
)]
|
||||||
|
SinkNotSource { aggregate: String, endpoint: String },
|
||||||
|
#[error("aggregate `{aggregate}` has default_sink `{endpoint}`, which is read-only")]
|
||||||
|
SinkNotWritable { aggregate: String, endpoint: String },
|
||||||
|
#[error("aggregate `{aggregate}` has max_delete_fraction {value}, which is outside 0.0..=1.0")]
|
||||||
|
DeleteFractionOutOfRange { aggregate: String, value: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Reads `path`, or the default location when `path` is `None`.
|
||||||
|
pub fn load(path: Option<&Path>) -> Result<(Self, PathBuf), ConfigError> {
|
||||||
|
let path = match path {
|
||||||
|
Some(path) => path.to_path_buf(),
|
||||||
|
None => paths::default_config_file()?,
|
||||||
|
};
|
||||||
|
let text = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
|
||||||
|
path: path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let config: Config = toml::from_str(&text).map_err(|source| ConfigError::Parse {
|
||||||
|
path: path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
if config.version != SUPPORTED_VERSION {
|
||||||
|
return Err(ConfigError::UnsupportedVersion {
|
||||||
|
found: config.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
config.validate()?;
|
||||||
|
Ok((config, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn endpoint(&self, id: &str) -> Option<&Endpoint> {
|
||||||
|
self.endpoints.iter().find(|endpoint| endpoint.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks every referential and policy constraint, reporting all failures at once.
|
||||||
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
let mut problems = Vec::new();
|
||||||
|
self.check_unique_ids(&mut problems);
|
||||||
|
for aggregate in &self.aggregates {
|
||||||
|
self.check_aggregate(aggregate, &mut problems);
|
||||||
|
}
|
||||||
|
if problems.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ConfigError::Invalid(problems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_unique_ids(&self, problems: &mut Vec<Problem>) {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for endpoint in &self.endpoints {
|
||||||
|
if !seen.insert(&endpoint.id) {
|
||||||
|
problems.push(Problem::DuplicateEndpoint(endpoint.id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for aggregate in &self.aggregates {
|
||||||
|
if !seen.insert(&aggregate.id) {
|
||||||
|
problems.push(Problem::DuplicateAggregate(aggregate.id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_aggregate(&self, aggregate: &Aggregate, problems: &mut Vec<Problem>) {
|
||||||
|
self.check_target(aggregate, problems);
|
||||||
|
self.check_sources(aggregate, problems);
|
||||||
|
self.check_sink(aggregate, problems);
|
||||||
|
if !(0.0..=1.0).contains(&aggregate.max_delete_fraction) {
|
||||||
|
problems.push(Problem::DeleteFractionOutOfRange {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
value: aggregate.max_delete_fraction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_target(&self, aggregate: &Aggregate, problems: &mut Vec<Problem>) {
|
||||||
|
let Some(target) = self.endpoint(&aggregate.target) else {
|
||||||
|
problems.push(Problem::UnknownEndpoint {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
endpoint: aggregate.target.clone(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !target.kind.is_writable() {
|
||||||
|
problems.push(Problem::TargetNotWritable {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
endpoint: target.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if aggregate.sources.contains(&aggregate.target) {
|
||||||
|
problems.push(Problem::TargetIsSource {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
endpoint: target.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_sources(&self, aggregate: &Aggregate, problems: &mut Vec<Problem>) {
|
||||||
|
if aggregate.sources.is_empty() {
|
||||||
|
problems.push(Problem::NoSources {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for source in &aggregate.sources {
|
||||||
|
if !seen.insert(source) {
|
||||||
|
problems.push(Problem::DuplicateSource {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
endpoint: source.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.endpoint(source).is_none() {
|
||||||
|
problems.push(Problem::UnknownEndpoint {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
endpoint: source.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_sink(&self, aggregate: &Aggregate, problems: &mut Vec<Problem>) {
|
||||||
|
let Some(sink) = aggregate.default_sink.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !aggregate.sources.contains(sink) {
|
||||||
|
problems.push(Problem::SinkNotSource {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
endpoint: sink.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(endpoint) = self.endpoint(sink)
|
||||||
|
&& !endpoint.kind.is_writable()
|
||||||
|
{
|
||||||
|
problems.push(Problem::SinkNotWritable {
|
||||||
|
aggregate: aggregate.id.clone(),
|
||||||
|
endpoint: sink.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Two writable endpoints plus a read-only feed, which most cases vary from.
|
||||||
|
const ENDPOINTS: &str = r#"
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[[endpoint]]
|
||||||
|
id = "work"
|
||||||
|
type = "caldav"
|
||||||
|
url = "https://caldav.example.com/work/"
|
||||||
|
username = "me@example.com"
|
||||||
|
|
||||||
|
[[endpoint]]
|
||||||
|
id = "gcal"
|
||||||
|
type = "google"
|
||||||
|
calendar_id = "primary"
|
||||||
|
client_id = "abc.apps.googleusercontent.com"
|
||||||
|
|
||||||
|
[[endpoint]]
|
||||||
|
id = "holidays"
|
||||||
|
type = "webcal"
|
||||||
|
url = "https://example.org/holidays.ics"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn parse(extra: &str) -> Config {
|
||||||
|
let text = format!("{ENDPOINTS}{extra}");
|
||||||
|
toml::from_str(&text).expect("test configuration should parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn problems(extra: &str) -> Vec<Problem> {
|
||||||
|
match parse(extra).validate() {
|
||||||
|
Ok(()) => Vec::new(),
|
||||||
|
Err(ConfigError::Invalid(problems)) => problems,
|
||||||
|
Err(other) => panic!("expected validation problems, got {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_a_well_formed_aggregate() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "gcal"
|
||||||
|
sources = ["work", "holidays"]
|
||||||
|
default_sink = "work"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(found, Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applies_documented_defaults() {
|
||||||
|
let config = parse(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "gcal"
|
||||||
|
sources = ["work"]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let aggregate = &config.aggregates[0];
|
||||||
|
assert_eq!(aggregate.conflict, ConflictPolicy::SourceWins);
|
||||||
|
assert!(aggregate.propagate_deletes);
|
||||||
|
assert_eq!(aggregate.max_delete_fraction, 0.2);
|
||||||
|
assert_eq!(aggregate.default_sink, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_read_only_target() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "holidays"
|
||||||
|
sources = ["work"]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
vec![Problem::TargetNotWritable {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
endpoint: "holidays".into(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_aggregate_that_would_sync_into_itself() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "gcal"
|
||||||
|
sources = ["gcal", "work"]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
vec![Problem::TargetIsSource {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
endpoint: "gcal".into(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_sink_that_is_not_a_source() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "gcal"
|
||||||
|
sources = ["holidays"]
|
||||||
|
default_sink = "work"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
vec![Problem::SinkNotSource {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
endpoint: "work".into(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_read_only_sink() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "gcal"
|
||||||
|
sources = ["holidays"]
|
||||||
|
default_sink = "holidays"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
vec![Problem::SinkNotWritable {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
endpoint: "holidays".into(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_unknown_endpoint_references() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "nowhere"
|
||||||
|
sources = ["missing"]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
vec![
|
||||||
|
Problem::UnknownEndpoint {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
endpoint: "nowhere".into(),
|
||||||
|
},
|
||||||
|
Problem::UnknownEndpoint {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
endpoint: "missing".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_out_of_range_delete_fraction() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "gcal"
|
||||||
|
sources = ["work"]
|
||||||
|
max_delete_fraction = 1.5
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found,
|
||||||
|
vec![Problem::DeleteFractionOutOfRange {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
value: 1.5,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_every_problem_in_one_pass() {
|
||||||
|
let found = problems(
|
||||||
|
r#"
|
||||||
|
[[endpoint]]
|
||||||
|
id = "work"
|
||||||
|
type = "caldav"
|
||||||
|
url = "https://caldav.example.com/other/"
|
||||||
|
username = "me@example.com"
|
||||||
|
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "holidays"
|
||||||
|
sources = []
|
||||||
|
|
||||||
|
[[aggregate]]
|
||||||
|
id = "unified"
|
||||||
|
target = "gcal"
|
||||||
|
sources = ["work", "work"]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert!(found.contains(&Problem::DuplicateEndpoint("work".into())));
|
||||||
|
assert!(found.contains(&Problem::DuplicateAggregate("unified".into())));
|
||||||
|
assert!(found.contains(&Problem::NoSources {
|
||||||
|
aggregate: "unified".into()
|
||||||
|
}));
|
||||||
|
assert!(found.contains(&Problem::DuplicateSource {
|
||||||
|
aggregate: "unified".into(),
|
||||||
|
endpoint: "work".into(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/doctor.rs
Normal file
126
src/doctor.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
//! `calcalist doctor` — verifies the environment before anything is synchronised.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::config::{Config, ConfigError};
|
||||||
|
use crate::paths;
|
||||||
|
use crate::pimsync;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Ok(String),
|
||||||
|
Warn(String),
|
||||||
|
Fail(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
fn marker(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Ok(_) => "ok",
|
||||||
|
Self::Warn(_) => "warn",
|
||||||
|
Self::Fail(_) => "fail",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detail(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Ok(detail) | Self::Warn(detail) | Self::Fail(detail) => detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Check {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub outcome: Outcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Check {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{:<16} {:<5} {}",
|
||||||
|
self.name,
|
||||||
|
self.outcome.marker(),
|
||||||
|
self.outcome.detail()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs every check. Returns the checks in the order they should be printed.
|
||||||
|
pub fn run(config_path: Option<&Path>) -> Vec<Check> {
|
||||||
|
vec![
|
||||||
|
check_pimsync(),
|
||||||
|
check_state_dir(),
|
||||||
|
check_config(config_path),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn any_failed(checks: &[Check]) -> bool {
|
||||||
|
checks
|
||||||
|
.iter()
|
||||||
|
.any(|check| matches!(check.outcome, Outcome::Fail(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_pimsync() -> Check {
|
||||||
|
let outcome = match pimsync::probe() {
|
||||||
|
Ok(version) if version.is_supported() => Outcome::Ok(version.to_string()),
|
||||||
|
Ok(version) => Outcome::Warn(format!(
|
||||||
|
"{version} is outside the supported {}.{}.x series; config syntax may differ",
|
||||||
|
pimsync::SUPPORTED_MAJOR,
|
||||||
|
pimsync::SUPPORTED_MINOR
|
||||||
|
)),
|
||||||
|
Err(error) => Outcome::Fail(error.to_string()),
|
||||||
|
};
|
||||||
|
Check {
|
||||||
|
name: "pimsync",
|
||||||
|
outcome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_state_dir() -> Check {
|
||||||
|
let outcome = match paths::state_dir() {
|
||||||
|
Ok(dir) => match fs::create_dir_all(&dir) {
|
||||||
|
Ok(()) => Outcome::Ok(dir.display().to_string()),
|
||||||
|
Err(error) => Outcome::Fail(format!("{}: {error}", dir.display())),
|
||||||
|
},
|
||||||
|
Err(error) => Outcome::Fail(error.to_string()),
|
||||||
|
};
|
||||||
|
Check {
|
||||||
|
name: "state directory",
|
||||||
|
outcome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_config(path: Option<&Path>) -> Check {
|
||||||
|
let outcome = match Config::load(path) {
|
||||||
|
Ok((config, path)) => Outcome::Ok(format!(
|
||||||
|
"{} ({} endpoint(s), {} aggregate(s))",
|
||||||
|
path.display(),
|
||||||
|
config.endpoints.len(),
|
||||||
|
config.aggregates.len()
|
||||||
|
)),
|
||||||
|
Err(ConfigError::Read { path, source })
|
||||||
|
if source.kind() == std::io::ErrorKind::NotFound =>
|
||||||
|
{
|
||||||
|
Outcome::Warn(format!(
|
||||||
|
"{} does not exist yet; run `calcalist serve` to create one",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(ConfigError::Invalid(problems)) => {
|
||||||
|
let listed = problems
|
||||||
|
.iter()
|
||||||
|
.map(|problem| format!("\n - {problem}"))
|
||||||
|
.collect::<String>();
|
||||||
|
Outcome::Fail(format!("{} problem(s):{listed}", problems.len()))
|
||||||
|
}
|
||||||
|
Err(error) => Outcome::Fail(error.to_string()),
|
||||||
|
};
|
||||||
|
Check {
|
||||||
|
name: "configuration",
|
||||||
|
outcome,
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main.rs
Normal file
47
src/main.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
//! calcalist — aggregate and sync events between CalDAV, Google Calendar and iCal feeds.
|
||||||
|
|
||||||
|
mod cli;
|
||||||
|
mod config;
|
||||||
|
mod doctor;
|
||||||
|
mod paths;
|
||||||
|
mod pimsync;
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use crate::cli::{Cli, Command};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match &cli.command {
|
||||||
|
Command::Doctor => run_doctor(&cli),
|
||||||
|
other => unimplemented(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_doctor(cli: &Cli) -> ExitCode {
|
||||||
|
let checks = doctor::run(cli.config.as_deref());
|
||||||
|
for check in &checks {
|
||||||
|
println!("{check}");
|
||||||
|
}
|
||||||
|
if doctor::any_failed(&checks) {
|
||||||
|
ExitCode::FAILURE
|
||||||
|
} else {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M0 ships the skeleton: the command surface is defined, but only `doctor` acts.
|
||||||
|
fn unimplemented(command: &Command) -> ExitCode {
|
||||||
|
let name = match command {
|
||||||
|
Command::Sync { .. } => "sync",
|
||||||
|
Command::Status => "status",
|
||||||
|
Command::Serve { .. } => "serve",
|
||||||
|
Command::Google { .. } => "google",
|
||||||
|
Command::Aggregate { .. } => "aggregate",
|
||||||
|
Command::Doctor => "doctor",
|
||||||
|
};
|
||||||
|
eprintln!("calcalist: `{name}` is not implemented yet (M0 provides `doctor` only)");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
45
src/paths.rs
Normal file
45
src/paths.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
//! XDG base directory resolution.
|
||||||
|
//!
|
||||||
|
//! Config is portable and user-editable; everything else is machine-local state
|
||||||
|
//! that must never travel with the config file.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const APP: &str = "calcalist";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PathError {
|
||||||
|
#[error("neither ${0} nor $HOME is set; cannot locate the {1} directory")]
|
||||||
|
NoBase(&'static str, &'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$XDG_CONFIG_HOME/calcalist`, falling back to `~/.config/calcalist`.
|
||||||
|
pub fn config_dir() -> Result<PathBuf, PathError> {
|
||||||
|
resolve("XDG_CONFIG_HOME", ".config", "configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `$XDG_STATE_HOME/calcalist`, falling back to `~/.local/state/calcalist`.
|
||||||
|
pub fn state_dir() -> Result<PathBuf, PathError> {
|
||||||
|
resolve("XDG_STATE_HOME", ".local/state", "state")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The config file used when `--config` is not given.
|
||||||
|
pub fn default_config_file() -> Result<PathBuf, PathError> {
|
||||||
|
Ok(config_dir()?.join("calcalist.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(var: &'static str, fallback: &str, purpose: &'static str) -> Result<PathBuf, PathError> {
|
||||||
|
if let Some(base) = non_empty(var) {
|
||||||
|
return Ok(PathBuf::from(base).join(APP));
|
||||||
|
}
|
||||||
|
let home = non_empty("HOME").ok_or(PathError::NoBase(var, purpose))?;
|
||||||
|
Ok(PathBuf::from(home).join(fallback).join(APP))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Treats an empty variable as unset, as the XDG spec requires.
|
||||||
|
fn non_empty(var: &str) -> Option<String> {
|
||||||
|
env::var(var).ok().filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
134
src/pimsync.rs
Normal file
134
src/pimsync.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
//! Driving the `pimsync` binary.
|
||||||
|
//!
|
||||||
|
//! pimsync handles the CalDAV and WebCal legs, mirroring each remote into a local
|
||||||
|
//! vdir. It is invoked one-shot: its own daemon would race the reconciler over the
|
||||||
|
//! same files, so scheduling belongs to calcalist.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::io;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub const BINARY: &str = "pimsync";
|
||||||
|
|
||||||
|
/// The `0.5.x` series this build generates configuration for. pimsync is
|
||||||
|
/// pre-1.0 and its config syntax may change between minor releases, so the
|
||||||
|
/// range is deliberately narrow.
|
||||||
|
pub const SUPPORTED_MAJOR: u64 = 0;
|
||||||
|
pub const SUPPORTED_MINOR: u64 = 5;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Version {
|
||||||
|
pub major: u64,
|
||||||
|
pub minor: u64,
|
||||||
|
pub patch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Version {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Version {
|
||||||
|
pub fn is_supported(&self) -> bool {
|
||||||
|
self.major == SUPPORTED_MAJOR && self.minor == SUPPORTED_MINOR
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the `pimsync 0.5.11` line printed by `pimsync version`.
|
||||||
|
fn parse(output: &str) -> Option<Self> {
|
||||||
|
let field = output.split_whitespace().nth(1)?;
|
||||||
|
let mut parts = field.split('.');
|
||||||
|
Some(Version {
|
||||||
|
major: parts.next()?.parse().ok()?,
|
||||||
|
minor: parts.next()?.parse().ok()?,
|
||||||
|
patch: parts.next().unwrap_or("0").parse().ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PimsyncError {
|
||||||
|
#[error("`{BINARY}` was not found on PATH; install it (devbox provides {BINARY}@0.5.11)")]
|
||||||
|
NotFound,
|
||||||
|
#[error("could not run `{BINARY}`: {0}")]
|
||||||
|
Spawn(#[source] io::Error),
|
||||||
|
#[error("`{BINARY} version` failed: {0}")]
|
||||||
|
Failed(String),
|
||||||
|
#[error("could not read a version out of `{0}`")]
|
||||||
|
Unparseable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the installed pimsync for its version.
|
||||||
|
pub fn probe() -> Result<Version, PimsyncError> {
|
||||||
|
let output = Command::new(BINARY)
|
||||||
|
.arg("version")
|
||||||
|
.output()
|
||||||
|
.map_err(|error| {
|
||||||
|
if error.kind() == io::ErrorKind::NotFound {
|
||||||
|
PimsyncError::NotFound
|
||||||
|
} else {
|
||||||
|
PimsyncError::Spawn(error)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(PimsyncError::Failed(
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let line = text.lines().next().unwrap_or_default().trim();
|
||||||
|
Version::parse(line).ok_or_else(|| PimsyncError::Unparseable(line.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_the_version_line() {
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("pimsync 0.5.11"),
|
||||||
|
Some(Version {
|
||||||
|
major: 0,
|
||||||
|
minor: 5,
|
||||||
|
patch: 11
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tolerates_a_two_component_version() {
|
||||||
|
assert_eq!(
|
||||||
|
Version::parse("pimsync 1.2"),
|
||||||
|
Some(Version {
|
||||||
|
major: 1,
|
||||||
|
minor: 2,
|
||||||
|
patch: 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unparseable_output() {
|
||||||
|
assert_eq!(Version::parse("pimsync"), None);
|
||||||
|
assert_eq!(Version::parse("pimsync unreleased"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_supported_series_is_accepted() {
|
||||||
|
let supported = Version {
|
||||||
|
major: 0,
|
||||||
|
minor: 5,
|
||||||
|
patch: 0,
|
||||||
|
};
|
||||||
|
let next_series = Version {
|
||||||
|
major: 0,
|
||||||
|
minor: 6,
|
||||||
|
patch: 0,
|
||||||
|
};
|
||||||
|
assert!(supported.is_supported());
|
||||||
|
assert!(!next_series.is_supported());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue