feat(rhema-code): add the colors setting with four palettes

The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
This commit is contained in:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

@@ -0,0 +1,366 @@
//! The CalDAV adapter: one `REPORT` per poll against a calendar
//! collection, the occurrences read out of the iCalendar objects the
//! server answers with.
//!
//! Wire choices and the constraints they pin:
//! - The request is a `CALDAV:calendar-query` `REPORT` with a
//! `time-range` on `VEVENT` and `Depth: 1`. The URL is the calendar
//! collection's own, a copy-paste from the server UI; principal and
//! home-set discovery are deliberately absent, because a hand-pasted
//! URL is one config line and discovery is a protocol zoo.
//! - The answer is a 207 multistatus whose `calendar-data` values are
//! whole iCalendar objects. Servers return recurring masters plus
//! overrides for the range, so the expansion in [`crate::ics`] does
//! the client-side work and this module only fetches and parses.
//! Occurrence ids carry the `caldav` prefix, so a calendar moved
//! between adapters changes its dedup ids: feed URLs and DAV
//! collections are different identities, not spellings of one.
//! - Auth is Basic (username plus password, the self-hosted norm), a
//! bearer token, or anonymous, whichever the server wants.
//! Credentials ride the `Authorization` header only, never a URL or
//! a log line.
//! - A `time-range` `REPORT` filters server-side by overlap, so the
//! adapter still clips client-side to the window's own contract:
//! starts within `[from, to)`.
//! - Non-event objects in the collection (a `VTODO` stored beside the
//! events) expand to nothing. A `VEVENT` without `UID` or `DTSTART`
//! fails the poll loudly, as in the ics adapter: a shape break must
//! surface, not shrink the calendar.
use std::time::Duration;
use async_trait::async_trait;
use quick_xml::Reader;
use quick_xml::escape::unescape;
use quick_xml::events::Event as XmlEvent;
use time::OffsetDateTime;
use crate::calendar::{Calendar, finish};
use crate::error::{CalendarError, ensure_success};
use crate::event::{CalendarEvent, Window};
use crate::ics::expand_as;
/// The production spelling of nothing in particular: CalDAV has no
/// vendor base to default to, the calendar's URL is configured whole.
pub const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION"));
/// How the adapter authenticates.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Auth {
/// No credentials; servers that publish openly.
Anonymous,
/// Username plus password, the self-hosted norm.
Basic { user: String, password: String },
/// A bearer token, for servers that issue them.
Bearer(String),
}
/// CalDAV backend: a collection URL, an auth choice, one reqwest
/// client.
pub struct CaldavCalendar {
url: String,
auth: Auth,
http: reqwest::Client,
report: reqwest::Method,
}
impl CaldavCalendar {
/// A calendar collection at `url` (`https://` or `http://`; a
/// self-hosted collection on the LAN is a legitimate http
/// citizen). Anonymous until told otherwise.
pub fn new(url: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
// One connect attempt: a dead server fails fast instead of
// stalling the poll loop.
.connect_timeout(Duration::from_secs(10))
// Every DAV call is a bounded request/response, so one
// overall timeout per request is the right guard.
.timeout(Duration::from_secs(20))
.user_agent(USER_AGENT)
.build()
.expect("a client builder with only timeouts cannot fail");
Self {
url: url.into().trim_end_matches('/').to_owned(),
auth: Auth::Anonymous,
http,
// REPORT is an HTTP token, so this construction cannot
// fail; reqwest has no const constructor for it.
report: reqwest::Method::from_bytes(b"REPORT")
.expect("REPORT is a valid HTTP method token"),
}
}
/// Authenticates with Basic credentials.
pub fn with_basic(mut self, user: impl Into<String>, password: impl Into<String>) -> Self {
self.auth = Auth::Basic {
user: user.into(),
password: password.into(),
};
self
}
/// Authenticates with a bearer token.
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.auth = Auth::Bearer(token.into());
self
}
}
#[async_trait]
impl Calendar for CaldavCalendar {
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
let mut request = self
.http
.request(self.report.clone(), &self.url)
.header("Depth", "1")
.header(
reqwest::header::CONTENT_TYPE,
"application/xml; charset=utf-8",
)
.header(reqwest::header::ACCEPT, "application/xml")
.body(report_body(window));
match &self.auth {
Auth::Anonymous => {}
Auth::Basic { user, password } => {
request = request.basic_auth(user, Some(password));
}
Auth::Bearer(token) => {
request = request.bearer_auth(token);
}
}
let response = request.send().await.map_err(CalendarError::network)?;
let body = ensure_success(response)
.await?
.text()
.await
.map_err(CalendarError::network)?;
let mut events = Vec::new();
for object in parse_multistatus(&body)? {
events.extend(expand_as(&object, "caldav", window)?);
}
Ok(finish(events, window))
}
}
/// The `calendar-query` body: the collection's VEVENTs, bounded by the
/// window's `time-range`. The bounds are RFC 4791's own spelling:
/// compact, UTC, `Z`.
fn report_body(window: &Window) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>"#,
start = compact_z(window.from),
end = compact_z(window.to),
)
}
/// An instant as a CalDAV `time-range` bound: `YYYYMMDDTHHMMSSZ` in
/// UTC, whatever offset the instant carried.
fn compact_z(at: OffsetDateTime) -> String {
let at = at.to_offset(time::UtcOffset::UTC);
format!(
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
at.year(),
u8::from(at.month()),
at.day(),
at.hour(),
at.minute(),
at.second()
)
}
/// An element's local name: CalDAV's prefixes (`D:`, `C:`) are
/// conventions, not guarantees, and the answer's meaning lives in the
/// namespace URIs the element binds. Matching local names inside a
/// `prop` stat is the pragmatic read every DAV client ships.
fn local_name(name: quick_xml::name::QName<'_>) -> &str {
// The reader was built from a &str, so every borrowed name is
// UTF-8 already; a decoding failure cannot say anything truer than
// "not the element we were looking for".
std::str::from_utf8(name.local_name().into_inner()).unwrap_or("")
}
/// Pulls every `calendar-data` value out of a 207 multistatus, in
/// document order. Whitespace-only values are dropped; anything else
/// the server said (hrefs, etags, statuses) is beneath the vocabulary.
fn parse_multistatus(xml: &str) -> Result<Vec<String>, CalendarError> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut datas = Vec::new();
let mut capturing = false;
let mut current = String::new();
loop {
match reader.read_event() {
Ok(XmlEvent::Start(element)) => {
if local_name(element.name()) == "calendar-data" {
capturing = true;
current.clear();
}
}
Ok(XmlEvent::Text(text)) if capturing => {
// Escaped fragments ride Text; references ride their
// own event (below). A server writes `&#13;&#10;`
// where the iCalendar needs CRLF, and an unexpanded
// reference would fold the whole object into one line
// and read as quietly empty.
let decoded = text
.xml10_content()
.map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?;
let decoded = unescape(&decoded)
.map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?;
current.push_str(&decoded);
}
Ok(XmlEvent::GeneralRef(reference)) if capturing => {
match reference.resolve_char_ref() {
// `&#13;` and friends: one character.
Ok(Some(ch)) => current.push(ch),
// A named entity (`&amp;`): the escape table's.
Ok(None) => {
let raw = reference.xml10_content().map_err(|err| {
CalendarError::Parse(format!("multistatus text: {err}"))
})?;
let entity = format!("&{raw};");
let decoded = unescape(&entity).map_err(|err| {
CalendarError::Parse(format!("multistatus text: {err}"))
})?;
current.push_str(&decoded);
}
Err(err) => {
return Err(CalendarError::Parse(format!("multistatus text: {err}")));
}
}
}
Ok(XmlEvent::CData(text)) if capturing => {
current.push_str(&String::from_utf8_lossy(text.as_ref()));
}
Ok(XmlEvent::End(element)) => {
if capturing && local_name(element.name()) == "calendar-data" {
capturing = false;
if !current.trim().is_empty() {
datas.push(std::mem::take(&mut current));
}
}
}
Ok(XmlEvent::Eof) => break,
Ok(_) => {}
Err(err) => return Err(CalendarError::Parse(format!("multistatus: {err}"))),
}
}
Ok(datas)
}
#[cfg(test)]
mod tests {
use super::*;
use time::format_description::well_known::Rfc3339;
fn window(start: &str, hours: i64) -> Window {
let from = OffsetDateTime::parse(start, &time::format_description::well_known::Rfc3339)
.expect("parses");
Window::new(from, from + time::Duration::hours(hours))
}
#[test]
fn time_range_bounds_are_compact_utc() {
// An offset instant normalizes: +02:00 noon is 10:00Z.
let at = OffsetDateTime::parse("2026-10-20T12:00:00+02:00", &Rfc3339).expect("parses");
assert_eq!(compact_z(at), "20261020T100000Z");
assert_eq!(
compact_z(window("2026-10-20T00:00:00Z", 24).to),
"20261021T000000Z"
);
}
#[test]
fn the_report_names_the_window_in_caldavs_own_spelling() {
let body = report_body(&window("2026-10-20T00:00:00Z", 24));
assert!(
body.contains(r#"<C:time-range start="20261020T000000Z" end="20261021T000000Z"/>"#)
);
assert!(body.contains(r#"<C:comp-filter name="VEVENT">"#));
assert!(body.contains("calendar-query"));
}
#[test]
fn multistatus_yields_every_calendar_data_in_order() {
let xml = r#"<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/c/me/home/a.ics</D:href>
<D:propstat>
<D:prop>
<D:getetag>"1"</D:getetag>
<C:calendar-data>BEGIN:VCALENDAR
FIRST:a
END:VCALENDAR</C:calendar-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/c/me/home/b.ics</D:href>
<D:propstat>
<D:prop>
<C:calendar-data>BEGIN:VCALENDAR
FIRST:b
END:VCALENDAR</C:calendar-data>
</D:prop>
</D:propstat>
</D:response>
</D:multistatus>"#;
let datas = parse_multistatus(xml).expect("parses");
assert_eq!(datas.len(), 2);
assert!(datas[0].contains("FIRST:a"));
assert!(datas[1].contains("FIRST:b"));
}
#[test]
fn empty_and_missing_calendar_data_contribute_nothing() {
let xml = r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:propstat>
<D:prop>
<C:calendar-data/>
</D:prop>
</D:propstat>
</D:response>
<D:response>
<D:propstat>
<D:prop>
<D:getetag>"2"</D:getetag>
</D:prop>
</D:propstat>
</D:response>
</D:multistatus>"#;
assert!(parse_multistatus(xml).expect("parses").is_empty());
}
#[test]
fn garbage_xml_is_a_loud_parse_error() {
let err = parse_multistatus("<not closed").expect_err("loud");
assert!(matches!(err, CalendarError::Parse(_)), "got: {err:?}");
}
#[test]
fn a_vtodo_only_collection_expands_to_nothing() {
// expand_as is the shared expansion; the CalDAV spelling of it
// must treat a non-event object as quiet, not broken.
let object = "BEGIN:VCALENDAR\nBEGIN:VTODO\nUID:t@x\nDTSTART:20261020T140000Z\n\
END:VTODO\nEND:VCALENDAR\n";
let events =
expand_as(object, "caldav", &window("2026-10-20T00:00:00Z", 24)).expect("expands");
assert!(events.is_empty());
}
}
@@ -0,0 +1,89 @@
//! The calendar client contract: one trait, one call.
//!
//! [`Calendar`] is the whole surface a consumer needs: the occurrences
//! starting inside a window. Implementations are cheap to clone and
//! share (the reqwest client inside is reference-counted) and
//! object-safe so a backend can be swapped without generics.
//!
//! Polls are at-least-once and window-anchored. Nothing here tracks a
//! cursor, because a calendar is addressable by time, not by history:
//! re-asking the same window returns the same rows under the same
//! ids, and the consumer's dispatcher dedups the repetition. There is
//! deliberately no write half: reminders read a calendar; what a
//! consumer does about one is an agent's business, not an adapter's.
use async_trait::async_trait;
use crate::error::CalendarError;
use crate::event::{CalendarEvent, Window};
/// A calendar backend: the client for one feed dialect, bound to one
/// calendar (the URL for ICS, the calendar id for Google).
#[async_trait]
pub trait Calendar: Send + Sync {
/// The occurrences whose start falls in `window`, oldest first,
/// ties broken by id. Recurring series arrive expanded: one
/// [`CalendarEvent`] per occurrence, overrides applied, cancelled
/// occurrences gone.
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError>;
}
/// Folds collected events into an adapter's answer: keeps the rows
/// starting in `window`, drops duplicate ids (first wins), and sorts
/// by `(start, id)`. Adapters call this before returning, so a
/// consumer reads one deterministic order regardless of which feed
/// produced the rows.
pub(crate) fn finish(mut events: Vec<CalendarEvent>, window: &Window) -> Vec<CalendarEvent> {
events.retain(|event| window.starts_within(event.start));
events.sort_by(|a, b| (&a.start, &a.id).cmp(&(&b.start, &b.id)));
events.dedup_by(|a, b| a.id == b.id);
events
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn event(id: &str, start: &str) -> CalendarEvent {
CalendarEvent {
id: id.to_owned(),
title: "x".to_owned(),
start: time::OffsetDateTime::parse(
start,
&time::format_description::well_known::Rfc3339,
)
.expect("parses"),
end: None,
all_day: false,
location: None,
description: None,
attendees: Vec::new(),
organizer: None,
url: None,
raw: json!({}),
}
}
#[test]
fn finish_clips_sorts_and_dedups() {
let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z"));
let events = finish(
vec![
event("b", "2026-10-20T10:00:00Z"),
event("z", "2026-10-19T10:00:00Z"), // before: dropped
event("a", "2026-10-20T10:00:00Z"),
event("b", "2026-10-20T10:00:00Z"), // duplicate id: dropped
event("c", "2026-10-21T00:00:00Z"), // at `to`: dropped
],
&window,
);
let ids: Vec<&str> = events.iter().map(|event| event.id.as_str()).collect();
assert_eq!(ids, ["a", "b"]);
}
fn at(raw: &str) -> time::OffsetDateTime {
time::OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
.expect("parses")
}
}
@@ -0,0 +1,76 @@
//! Why a calendar call failed.
//!
//! One error type for the whole crate: every adapter maps its transport,
//! HTTP, and decoding failures onto it, so callers match one shape no
//! matter which feed answered.
/// Everything that can go wrong between a [`crate::Calendar`] call and
/// its answer.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CalendarError {
/// The feed answered with a non-2xx status. `body` is a bounded
/// snippet of the response body, usually the server's error page or
/// JSON.
#[error("calendar api error (HTTP {status}): {body}")]
Api {
/// The HTTP status the feed answered with.
status: u16,
/// A bounded snippet of the response body.
body: String,
},
/// A response body could not be decoded, or a wire row is missing a
/// field the normalized vocabulary requires. Loud by design: feeds
/// are machine-generated, and a shape break must surface, not
/// silently shrink the calendar.
#[error("malformed calendar response: {0}")]
Parse(String),
/// The server kept offering full pages, or offered a page cursor
/// that leads nowhere. Polling stopped rather than looping forever.
#[error("pagination failed: {0}")]
Pagination(String),
/// The request never got an answer (DNS, connect, timeout).
#[error("network error: {0}")]
Network(String),
}
/// The longest body snippet an [`CalendarError::Api`] keeps: enough for
/// any server's error JSON, small enough that error lines stay bounded.
const BODY_SNIPPET_CAP: usize = 512;
impl CalendarError {
/// Builds [`CalendarError::Api`] from a non-2xx status and the raw
/// body, truncating the body to [`BODY_SNIPPET_CAP`] characters.
pub(crate) fn api(status: reqwest::StatusCode, raw: &str) -> Self {
let body: String = raw.chars().take(BODY_SNIPPET_CAP).collect();
CalendarError::Api {
status: status.as_u16(),
body,
}
}
/// Maps a transport failure onto [`CalendarError::Network`];
/// reqwest's error is neither `Clone` nor `Eq`, so only its display
/// text travels.
pub(crate) fn network(err: reqwest::Error) -> Self {
CalendarError::Network(err.to_string())
}
}
/// A wire field the normalized vocabulary cannot do without. A missing
/// one is a loud [`CalendarError::Parse`], not a silently skipped row.
pub(crate) fn required<T>(field: Option<T>, what: &str) -> Result<T, CalendarError> {
field.ok_or_else(|| CalendarError::Parse(format!("calendar row without {what}")))
}
/// Passes a 2xx response through; turns anything else into
/// [`CalendarError::Api`] with a bounded body snippet.
pub(crate) async fn ensure_success(
response: reqwest::Response,
) -> Result<reqwest::Response, CalendarError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let body = response.text().await.unwrap_or_default();
Err(CalendarError::api(status, &body))
}
@@ -0,0 +1,161 @@
//! The normalized event vocabulary shared by every calendar backend.
//!
//! Constraints the normalization pins (each adapter module documents how
//! it meets them):
//! - An event is one occurrence, never a series. Recurring masters are
//! expanded (by the Google server with `singleEvents=true`, by the
//! `ics` adapter client-side), so a consumer never sees a rule, only
//! starts it can compare against its clock.
//! - [`CalendarEvent::id`] is the dedup id a dispatcher keys on:
//! `{provider}:{uid}@{unix start}`. Two polls of the same occurrence
//! produce the same id; the id changes only when the occurrence
//! itself is a different one. What a consumer does about the
//! repetition (dispatch dedup, reminder slots) is the consumer's
//! seam, not this crate's.
//! - Times are instants, not wall-clock strings. A wire time with no
//! offset reads as UTC, and an all-day date starts at its midnight
//! UTC with [`CalendarEvent::all_day`] set, so every `start`
//! compares against any clock without a timezone database on board.
//! - [`CalendarEvent::end`] is exclusive, as every wire format spells
//! it, and `None` when the row names no end.
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::serde::rfc3339;
/// One occurrence on a calendar: a meeting, a birthday, a block of
/// time. Everything a reminder needs to say, lifted from whichever
/// dialect produced it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CalendarEvent {
/// Stable dedup id, `{provider}:{uid}@{unix start}` (e.g.
/// `ics:c1f3a9@1792471200`,
/// `google:_70ojsm0k6nojajk6@1792471200`): one occurrence, one id,
/// across polls. A recurring series expands to one event per
/// occurrence, each with its own.
pub id: String,
/// The title (`SUMMARY` / `summary`); empty when the row has none.
pub title: String,
/// When the occurrence starts.
#[serde(with = "rfc3339")]
pub start: OffsetDateTime,
/// When it ends, exclusive; `None` when the row names no end.
#[serde(with = "rfc3339::option")]
pub end: Option<OffsetDateTime>,
/// Whether this is an all-day occurrence: the wire carried a date
/// with no time, and [`CalendarEvent::start`] is that date's
/// midnight UTC.
pub all_day: bool,
/// Where it happens, when the row says.
pub location: Option<String>,
/// The row's notes, when it has any.
pub description: Option<String>,
/// Who is invited, bare addresses (`mailto:` stripped, Google's
/// `email` fields).
pub attendees: Vec<String>,
/// The organizer's address, when the row names one.
pub organizer: Option<String>,
/// Human-facing URL of the event (`URL` / `htmlLink`).
pub url: Option<String>,
/// The untouched wire row: the adapter's parsed property map for
/// ICS, the JSON object for Google. Parameters the vocabulary does
/// not lift (`TZID`, for one) ride here for a consumer that can do
/// more with them.
pub raw: serde_json::Value,
}
impl CalendarEvent {
/// The event's duration: `end - start` when the row names an end,
/// zero otherwise. All-day rows without an `DTEND` are a zero-width
/// midnight: the wire says the day, the duration is not this
/// crate's guess to widen.
pub fn duration(&self) -> time::Duration {
self.end
.map(|end| end - self.start)
.unwrap_or(time::Duration::ZERO)
}
}
/// The stretch of time a poll asks about, as starts: an event belongs
/// to a window when its [`CalendarEvent::start`] falls inside
/// `[from, to)`. Windows are absolute (instants, not cursors), which
/// is why a calendar poll needs no cursor: re-asking the same window
/// returns the same rows under the same ids.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Window {
/// The earliest start included.
pub from: OffsetDateTime,
/// The latest start excluded.
pub to: OffsetDateTime,
}
impl Window {
/// A window from `from` (inclusive) to `to` (exclusive).
pub fn new(from: OffsetDateTime, to: OffsetDateTime) -> Self {
Self { from, to }
}
/// Whether `start` falls inside the window.
pub fn starts_within(&self, start: OffsetDateTime) -> bool {
start >= self.from && start < self.to
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(raw: &str) -> OffsetDateTime {
OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
.expect("rfc3339 parses")
}
#[test]
fn a_window_bounds_by_start_inclusively_below_exclusively() {
let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z"));
assert!(window.starts_within(at("2026-10-20T00:00:00Z")));
assert!(window.starts_within(at("2026-10-20T23:59:59Z")));
assert!(!window.starts_within(at("2026-10-19T23:59:59Z")));
assert!(!window.starts_within(at("2026-10-21T00:00:00Z")));
}
#[test]
fn an_event_round_trips_through_serde() {
let event = CalendarEvent {
id: "ics:abc@1".into(),
title: "standup".into(),
start: at("2026-10-20T09:00:00Z"),
end: Some(at("2026-10-20T09:15:00Z")),
all_day: false,
location: None,
description: None,
attendees: vec!["a@example.net".into()],
organizer: Some("b@example.net".into()),
url: None,
raw: serde_json::json!({ "SUMMARY": "standup" }),
};
let round: CalendarEvent =
serde_json::from_value(serde_json::to_value(&event).expect("serializes"))
.expect("deserializes");
assert_eq!(round, event);
assert_eq!(round.duration(), time::Duration::minutes(15));
}
#[test]
fn an_endless_event_has_no_duration() {
let event = CalendarEvent {
id: "ics:abc@1".into(),
title: String::new(),
start: at("2026-10-20T09:00:00Z"),
end: None,
all_day: true,
location: None,
description: None,
attendees: Vec::new(),
organizer: None,
url: None,
raw: serde_json::json!({}),
};
assert_eq!(event.duration(), time::Duration::ZERO);
}
}
@@ -0,0 +1,385 @@
//! The Google Calendar adapter: the Calendar API v3 events list.
//!
//! Auth is one credential, chosen by what the deployment has: an OAuth
//! access token in `Authorization: Bearer` (the header only; the token
//! never lands in a URL or a log line), or an API key for public
//! calendars, which Google only accepts as the `key` query parameter.
//!
//! Endpoint choices and the constraints they pin:
//! - `singleEvents=true` makes the server expand recurring masters into
//! one row per occurrence, each with a stable `{id}_{timestamp}` id,
//! so an occurrence's dedup id survives re-polls. The client side
//! never evaluates a recurrence rule.
//! - `timeMin`/`timeMax` bound the poll server-side. Google's filter is
//! overlap-based, so the adapter still clips client-side to the
//! window's own contract: starts within `[from, to)`.
//! - Rows with `status: cancelled` are dropped. On a singleEvents poll
//! a cancelled row is the tombstone of an occurrence that will not
//! happen; keeping it would remind about a meeting that was called
//! off.
//! - Pagination rides `pageToken` until it disappears, with a hard
//! page cap, so a server that keeps issuing cursors is declared
//! misbehaving rather than polled forever.
//! - `startTime` ordering plus `singleEvents` is the only ordering
//! Google allows over expanded instances, which is the order the
//! normalized answer promises anyway (oldest first, id-broken ties).
use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::Value;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::calendar::{Calendar, finish};
use crate::error::{CalendarError, ensure_success, required};
use crate::event::{CalendarEvent, Window};
/// The production Google APIs base.
pub const DEFAULT_BASE_URL: &str = "https://www.googleapis.com";
/// The user agent the adapter presents.
const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION"));
/// Rows requested per page: Google's own maximum.
const MAX_RESULTS: &str = "250";
/// Pages fetched before a server that keeps issuing cursors is
/// declared misbehaving rather than polled forever.
const MAX_PAGES: usize = 40;
/// Which credential a client authenticates with.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Credential {
/// An OAuth access token, in `Authorization: Bearer`.
Bearer(String),
/// An API key for public calendars, as the `key` parameter
/// Google requires.
ApiKey(String),
}
/// Google Calendar backend: a credential, a calendar id, one reqwest
/// client. The client is bound to one calendar, which is what the
/// [`Calendar`] contract promises anyway.
pub struct GoogleCalendar {
base_url: String,
calendar_id: String,
credential: Credential,
http: reqwest::Client,
}
impl GoogleCalendar {
/// A calendar reading with an OAuth access `token` (private and
/// public calendars both).
pub fn new(token: impl Into<String>, calendar_id: impl Into<String>) -> Self {
Self::with(Credential::Bearer(token.into()), calendar_id)
}
/// A calendar reading with an API `key`: public calendars only,
/// Google's rule, and the key rides the `key` query parameter
/// because that is the only form Google accepts for it.
pub fn with_api_key(key: impl Into<String>, calendar_id: impl Into<String>) -> Self {
Self::with(Credential::ApiKey(key.into()), calendar_id)
}
fn with(credential: Credential, calendar_id: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
// One connect attempt: a dead endpoint fails fast instead
// of stalling the poll loop.
.connect_timeout(Duration::from_secs(10))
// Every API call is a bounded request/response, so one
// overall timeout per request is the right guard.
.timeout(Duration::from_secs(20))
.user_agent(USER_AGENT)
.build()
.expect("a client builder with only timeouts cannot fail");
Self {
base_url: DEFAULT_BASE_URL.to_owned(),
calendar_id: calendar_id.into(),
credential,
http,
}
}
/// Overrides the base URL (trailing slashes trimmed) — for a test
/// against a mock server.
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_owned();
self
}
/// The events-list URL, with the calendar id percent-encoded by
/// the URL builder: ids are email-shaped (`primary`, an address,
/// a hex id), but the encoding is the builder's job, not ours.
fn endpoint(&self) -> Result<reqwest::Url, CalendarError> {
let mut url = reqwest::Url::parse(&self.base_url)
.map_err(|err| CalendarError::Parse(format!("base url: {err}")))?;
url.path_segments_mut()
.map_err(|_| CalendarError::Parse("base url is not a directory".to_owned()))?
.extend(["calendar", "v3", "calendars", &self.calendar_id, "events"]);
Ok(url)
}
}
#[async_trait]
impl Calendar for GoogleCalendar {
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
let time_min = window
.from
.format(&Rfc3339)
.map_err(|err| CalendarError::Parse(format!("window start: {err}")))?;
let time_max = window
.to
.format(&Rfc3339)
.map_err(|err| CalendarError::Parse(format!("window end: {err}")))?;
let mut rows = Vec::new();
let mut page_token = String::new();
for _page in 1..=MAX_PAGES {
let mut url = self.endpoint()?;
{
let mut query = url.query_pairs_mut();
query.append_pair("timeMin", &time_min);
query.append_pair("timeMax", &time_max);
query.append_pair("singleEvents", "true");
query.append_pair("orderBy", "startTime");
query.append_pair("maxResults", MAX_RESULTS);
if let Credential::ApiKey(key) = &self.credential {
query.append_pair("key", key);
}
if !page_token.is_empty() {
query.append_pair("pageToken", &page_token);
}
}
let mut request = self.http.get(url);
if let Credential::Bearer(token) = &self.credential {
request = request.bearer_auth(token);
}
let response = request.send().await.map_err(CalendarError::network)?;
let body = ensure_success(response)
.await?
.text()
.await
.map_err(CalendarError::network)?;
let page: EventsPage = serde_json::from_str(&body)
.map_err(|err| CalendarError::Parse(format!("events page: {err}")))?;
rows.extend(page.items);
match page.next_page_token {
Some(token) => page_token = token,
None => return Ok(finish(try_rows(rows)?, window)),
}
}
Err(CalendarError::Pagination(format!(
"more than {MAX_PAGES} pages from the events list"
)))
}
}
/// One events-list page: the raw rows travel as JSON so each can keep
/// riding its event's `raw` untouched.
#[derive(Debug, Deserialize)]
struct EventsPage {
#[serde(default)]
items: Vec<Value>,
#[serde(rename = "nextPageToken", default)]
next_page_token: Option<String>,
}
/// Decodes every row of a page, dropping the tombstones.
fn try_rows(rows: Vec<Value>) -> Result<Vec<CalendarEvent>, CalendarError> {
rows.into_iter()
.filter_map(|row| decode_row(row).transpose())
.collect()
}
/// Decodes one wire row; `None` for a cancelled tombstone. A missing
/// start is loud: without it there is no instant to remind about.
fn decode_row(row: Value) -> Result<Option<CalendarEvent>, CalendarError> {
let row: EventRow = serde_json::from_value(row)
.map_err(|err| CalendarError::Parse(format!("event row: {err}")))?;
if row.status.as_deref() == Some("cancelled") {
return Ok(None);
}
let start_block = required(row.start, "a start")?;
let (start, all_day) = start_block.read(&row.id)?;
let end = match row.end_time_unspecified {
true => None,
false => row
.end
.as_ref()
.map(|end| end.read(&row.id))
.transpose()?
.map(|(when, _)| when),
};
Ok(Some(CalendarEvent {
id: format!("google:{}@{}", row.id, start.unix_timestamp()),
title: row.summary.unwrap_or_default(),
start,
end,
all_day,
location: row.location,
description: row.description,
attendees: row
.attendees
.unwrap_or_default()
.into_iter()
.filter_map(|attendee| attendee.email)
.collect(),
organizer: row.organizer.and_then(|organizer| organizer.email),
url: row.html_link,
raw: row.raw,
}))
}
/// A wire time block: `dateTime` for a timed row, `date` for an
/// all-day one (that date's midnight UTC).
#[derive(Debug, Deserialize)]
struct TimeBlock {
#[serde(default)]
date: Option<String>,
#[serde(rename = "dateTime", default)]
date_time: Option<String>,
}
impl TimeBlock {
/// The instant plus the all-day flag; `None` when the block names
/// neither form. An unreadable spelling is loud, with the row's id
/// for context.
fn read(&self, id: &str) -> Result<(OffsetDateTime, bool), CalendarError> {
let loud = |what: &str, err: String| {
CalendarError::Parse(format!("event {id:?} has an unreadable {what}: {err}"))
};
if let Some(when) = self.date.as_deref() {
// `YYYY-MM-DD`, at midnight UTC.
let (year, rest) = when
.split_once('-')
.ok_or_else(|| loud("date", "no year separator".to_owned()))?;
let (month, day) = rest
.split_once('-')
.ok_or_else(|| loud("date", "no month separator".to_owned()))?;
let date = time::Date::from_calendar_date(
year.parse::<i32>()
.map_err(|err| loud("date year", err.to_string()))?,
time::Month::try_from(
month
.parse::<u8>()
.map_err(|err| loud("date month", err.to_string()))?,
)
.map_err(|err| loud("date month", err.to_string()))?,
day.parse::<u8>()
.map_err(|err| loud("date day", err.to_string()))?,
)
.map_err(|err| loud("date", err.to_string()))?;
return Ok((date.with_time(time::Time::MIDNIGHT).assume_utc(), true));
}
let when = self
.date_time
.as_deref()
.ok_or_else(|| loud("start", "neither date nor dateTime".to_owned()))?;
OffsetDateTime::parse(when, &Rfc3339)
.map(|start| (start, false))
.map_err(|err| loud("dateTime", err.to_string()))
}
}
/// The wire row, with the fields the vocabulary lifts named.
#[derive(Debug, Deserialize)]
struct EventRow {
id: String,
#[serde(default)]
status: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
location: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(rename = "htmlLink", default)]
html_link: Option<String>,
#[serde(default)]
start: Option<TimeBlock>,
#[serde(default)]
end: Option<TimeBlock>,
#[serde(rename = "endTimeUnspecified", default)]
end_time_unspecified: bool,
#[serde(default)]
attendees: Option<Vec<Party>>,
#[serde(default)]
organizer: Option<Party>,
/// Kept whole for [`CalendarEvent::raw`].
#[serde(flatten)]
raw: Value,
}
/// An attendee or organizer row; only the address is lifted.
#[derive(Debug, Deserialize)]
struct Party {
#[serde(default)]
email: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn timed(id: &str, start: &str) -> Value {
json!({
"id": id,
"status": "confirmed",
"summary": "standup",
"start": { "dateTime": start },
"end": { "dateTime": "2026-10-20T09:15:00Z" },
"htmlLink": "https://calendar.google.com/x",
"attendees": [ { "email": "a@example.net" }, { "displayName": "no address" } ]
})
}
#[test]
fn a_timed_row_decodes_with_its_lifts() {
let event = decode_row(timed("abc123", "2026-10-20T09:00:00Z"))
.expect("decodes")
.expect("not cancelled");
assert_eq!(
event.id,
format!(
"google:abc123@{}",
OffsetDateTime::parse("2026-10-20T09:00:00Z", &Rfc3339)
.expect("parses")
.unix_timestamp()
)
);
assert_eq!(event.title, "standup");
assert!(!event.all_day);
assert_eq!(event.attendees, vec!["a@example.net".to_owned()]);
assert_eq!(event.url.as_deref(), Some("https://calendar.google.com/x"));
}
#[test]
fn an_all_day_row_starts_at_its_midnight() {
let row = json!({
"id": "bday",
"summary": "birthday",
"start": { "date": "2026-10-20" }
});
let event = decode_row(row).expect("decodes").expect("not cancelled");
assert!(event.all_day);
assert_eq!(event.end, None);
assert_eq!(event.start.hour(), 0);
}
#[test]
fn a_cancelled_row_is_a_tombstone() {
let mut row = timed("abc123", "2026-10-20T09:00:00Z");
row["status"] = json!("cancelled");
assert_eq!(decode_row(row).expect("decodes"), None);
}
#[test]
fn a_row_without_a_start_is_loud() {
let mut row = timed("abc123", "2026-10-20T09:00:00Z");
row["start"] = json!({});
let err = decode_row(row).expect_err("loud");
assert!(err.to_string().contains("start"), "got: {err}");
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
//! `rhema-calendar-integration` — the calendar integration: one
//! normalized occurrence vocabulary and one client contract for the
//! calendar feeds a resident agent can remind about, with the adapters
//! that read them.
//!
//! An integration, not an agent: no driver, no loop, and no reminder
//! policy lives here. A consumer composes the crate with its own agent
//! tier — it picks a [`crate::Window`], polls a [`crate::Calendar`]
//! for the occurrences starting inside it, and decides what a start
//! time means (the claw daemon today: one reminder slot per lead time,
//! dispatched into the resident agent). Every [`crate::CalendarEvent`]
//! carries an id that stays the same across polls of the same
//! occurrence, so a consumer re-polling on its own cadence never
//! re-fires a reminder it already fed; the repetition is the
//! consumer's dispatcher's dedup, not this crate's problem.
//!
//! Recurrence is expanded before a poll answers, so a consumer never
//! sees a rule, only starts it can compare against its clock:
//! - The Google Calendar adapter asks the server to expand
//! (`singleEvents=true`).
//! - The ICS and CalDAV adapters expand client-side, covering the
//! rule shapes servers actually emit (daily, weekly with `BYDAY`,
//! monthly, yearly; `INTERVAL`, `COUNT`, `UNTIL`, `EXDATE`) and
//! applying `RECURRENCE-ID` overrides. Anything outside those
//! shapes surfaces once, at its `DTSTART`, never silently dropped.
//! CalDAV shares the exact expansion: the server returns masters
//! plus overrides in range, and the adapter reads them out of the
//! multistatus. Where a server exposes both, the feed URL and the
//! DAV collection are different identities: their occurrence ids
//! carry different provider prefixes.
//!
//! Times are instants, not wall-clock strings. Every wire format (RFC
//! 3339, iCalendar's compact `YYYYMMDDTHHMMSS[Z]`, `VALUE=DATE`
//! all-days) normalizes to a `time::OffsetDateTime`; a floating
//! (offset-less) time reads as UTC, and a `TZID` parameter rides the
//! event's `raw` for a consumer that can resolve zones.
//!
//! ```
//! use rhema_calendar_integration::{Calendar, IcsCalendar, Window};
//! use time::format_description::well_known::Rfc3339;
//!
//! // A feed is one URL (webcal counts as https); the poll is one GET.
//! let feed = IcsCalendar::new("https://example.net/nowhere/calendar.ics");
//! let from = time::OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339)
//! .expect("parses");
//! let window = Window::new(from, from + time::Duration::hours(24));
//! // `feed.events(&window).await` answers with the occurrences whose
//! // start falls inside the window, oldest first; this doctest only
//! // assembles them.
//! let _ = (&feed, &window);
//! ```
//!
//! Layout:
//! - [`event`] — the normalized vocabulary: `CalendarEvent`, `Window`
//! - [`error`] — the crate's single calendar error type
//! - [`calendar`] — the `Calendar` client contract
//! - [`ics`] — the ICS/webcal adapter (one GET, expanded client-side)
//! - [`caldav`] — the CalDAV adapter (one REPORT, the same expansion)
//! - [`google`] — the Google Calendar API adapter (expanded server-side)
#![forbid(unsafe_code)]
#![deny(missing_docs)]
pub mod caldav;
pub mod calendar;
pub mod error;
pub mod event;
pub mod google;
pub mod ics;
// Re-exported so implementors of the async seam can apply #[async_trait]
// without adding the crate themselves.
pub use async_trait::async_trait;
pub use caldav::CaldavCalendar;
pub use calendar::Calendar;
pub use error::CalendarError;
pub use event::{CalendarEvent, Window};
pub use google::GoogleCalendar;
pub use ics::IcsCalendar;