diff --git a/crates/rhema-claw/src/config.rs b/crates/rhema-claw/src/config.rs index 71da7d7..328e90f 100644 --- a/crates/rhema-claw/src/config.rs +++ b/crates/rhema-claw/src/config.rs @@ -15,6 +15,12 @@ use std::path::PathBuf; use rhema_forge_integration::RepoRef; use serde::{Deserialize, Serialize}; +// The calendar feed types are the integration's: the reminder source +// polls them there, and the claw's config only describes them. +pub use rhema_calendar_integration::reminder::{ + CalendarKind, CalendarWatch, DEFAULT_HORIZON_HOURS, DEFAULT_LEAD_MINUTES, +}; + /// How often the resident loop runs a detect pass, when /// [`ClawConfig::poll_interval_secs`] is unset. pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 60; @@ -29,16 +35,6 @@ pub const DEFAULT_MAX_TURN_STEPS: u32 = 2048; /// loopback only — the surface is for the machine's resident user. pub const DEFAULT_LISTEN: &str = "127.0.0.1:7799"; -/// The one reminder lead, in minutes, a calendar carries when -/// [`CalendarWatch::lead_minutes`] is unset: warn shortly before, and -/// let a busier lead list be configured where wanted. -pub const DEFAULT_LEAD_MINUTES: u64 = 15; - -/// How far ahead a calendar polls, in hours, when -/// [`CalendarWatch::horizon_hours`] is unset. Must exceed the longest -/// lead: a reminder cannot fire before the event is visible. -pub const DEFAULT_HORIZON_HOURS: u32 = 24; - /// Why a config operation failed. #[derive(Debug, thiserror::Error)] pub enum ConfigError { @@ -221,165 +217,6 @@ pub fn resolve_token( }) } -/// Which calendar feed a calendar watch polls. The wire spelling is -/// the source name the dispatcher registers and the reminder prompt -/// names. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CalendarKind { - /// An iCalendar feed over HTTP (`webcal://` counts): Google's - /// secret iCal address, a Nextcloud or Radicale share, Fastmail. - Ics, - /// A CalDAV collection, polled with a `calendar-query` REPORT: - /// Nextcloud, Radicale, Fastmail, a plain DAV server. - Caldav, - /// The Google Calendar API, against an OAuth access token or an - /// API key for public calendars. - Google, -} - -impl CalendarKind { - /// The spelling the config, the reminder source, and the prompts - /// use. - pub fn name(self) -> &'static str { - match self { - CalendarKind::Ics => "ics", - CalendarKind::Caldav => "caldav", - CalendarKind::Google => "google", - } - } - - /// Parses a calendar kind as the config spells it. `None` for - /// anything else. - pub fn parse(name: &str) -> Option { - match name.trim() { - "ics" => Some(CalendarKind::Ics), - "caldav" => Some(CalendarKind::Caldav), - "google" => Some(CalendarKind::Google), - _ => None, - } - } -} - -/// One watched calendar. Unknown keys are rejected, as on a watch. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -pub struct CalendarWatch { - /// The calendar's name: unique across the list; the reminder key - /// is `calendar:` and prompts name the calendar by it. - pub name: String, - /// Which feed kind this polls. - pub kind: CalendarKind, - /// The feed URL (`https://` or `webcal://`). Required for ics. - pub url: Option, - /// The Google calendar id (`primary`, an address, a hex id). - /// Required for google. - pub calendar_id: Option, - /// A bearer token (an OAuth access token for Google, auth for a - /// feed or collection that demands it). Falls back to - /// `CALENDAR_TOKEN`. - pub token: Option, - /// The CalDAV user, for Basic auth. Set only with a password (or - /// `CALDAV_PASSWORD`); a calendar with a username authenticates - /// Basic, and `token` is the fallback only when there is none. - pub username: Option, - /// The CalDAV password or app-specific password. Falls back to - /// `CALDAV_PASSWORD`. - pub password: Option, - /// A Google API key for a public calendar; wins over `token`. - pub api_key: Option, - /// Reminder lead times, minutes; each occurrence warns once per - /// lead. `[DEFAULT_LEAD_MINUTES]` when unset. - pub lead_minutes: Option>, - /// How far ahead the poll looks, hours. `[DEFAULT_HORIZON_HOURS]` - /// when unset; must exceed the longest lead. - pub horizon_hours: Option, -} - -impl CalendarWatch { - /// The reminder leads as configured, blank and zero entries - /// dropped; [`DEFAULT_LEAD_MINUTES`] alone when nothing usable is - /// configured. - pub fn leads(&self) -> Vec { - match self.lead_minutes.as_ref() { - Some(leads) => { - let usable: Vec = leads.iter().copied().filter(|lead| *lead > 0).collect(); - if usable.is_empty() { - vec![DEFAULT_LEAD_MINUTES] - } else { - usable - } - } - None => vec![DEFAULT_LEAD_MINUTES], - } - } - - /// The poll horizon as configured, [`DEFAULT_HORIZON_HOURS`] when - /// unset. Zero reads as the default: a horizon of nothing polls - /// nothing, and the config validation refuses it when it would - /// matter. - pub fn horizon(&self) -> u32 { - self.horizon_hours - .filter(|hours| *hours > 0) - .unwrap_or(DEFAULT_HORIZON_HOURS) - } - - /// A trimmed `token`/`api_key` value; blank counts as unset. - fn trimmed(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - } - - /// The API key as configured, if any. - pub fn resolved_api_key(&self) -> Option { - Self::trimmed(self.api_key.as_deref()) - } - - /// The CalDAV password to authenticate with: the config value, - /// then `CALDAV_PASSWORD`. Parameterized over the env lookup in - /// the pure [`CalendarWatch::resolve_password_with`]. - pub fn resolved_password(&self) -> Option { - Self::resolve_password_with(|name| std::env::var(name), self.password.as_deref()) - } - - /// Pure password resolution: the config value first, then - /// `CALDAV_PASSWORD`. Parameterized over the env lookup so tests - /// can hold the environment still. - pub fn resolve_password_with( - get_env: impl Fn(&str) -> Result, - stored: Option<&str>, - ) -> Option { - Self::trimmed(stored).or_else(|| { - get_env("CALDAV_PASSWORD") - .ok() - .and_then(|token| Self::trimmed(Some(&token))) - }) - } - - /// The token to poll with: the config value, then `CALENDAR_TOKEN`; - /// the first non-blank wins. Parameterized over the env lookup in - /// the pure [`CalendarWatch::resolve_token_with`]. - pub fn resolved_token(&self) -> Option { - Self::resolve_token_with(|name| std::env::var(name), self.token.as_deref()) - } - - /// Pure token resolution: the config value first, then - /// `CALENDAR_TOKEN`. Parameterized over the env lookup so tests can - /// hold the environment still. - pub fn resolve_token_with( - get_env: impl Fn(&str) -> Result, - stored: Option<&str>, - ) -> Option { - Self::trimmed(stored).or_else(|| { - get_env("CALENDAR_TOKEN") - .ok() - .and_then(|token| Self::trimmed(Some(&token))) - }) - } -} - /// Whether a `host:port` listen address binds beyond loopback. /// `localhost` (any case) and any loopback IP literal — `127.x.x.x`, /// `::1` — are loopback; an empty host (`:7799`, the bind-everything diff --git a/crates/rhema-claw/src/extension.rs b/crates/rhema-claw/src/extension.rs index ca0695c..dc22e6f 100644 --- a/crates/rhema-claw/src/extension.rs +++ b/crates/rhema-claw/src/extension.rs @@ -36,12 +36,12 @@ use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry}; use rhema_core::ToolResolver; use rhema_rsd::{Dispatcher, ResourceKey, SystemClock}; -use crate::calendar::CalendarSource; use crate::config::{CalendarWatch, ForgeKind}; use crate::consents::WaitBridge; use crate::daemon::ClawError; use crate::forge::ForgeSource; use crate::hub::EventHub; +use rhema_calendar_integration::CalendarSource; /// The store handles the standard set is assembled over. One struct, /// so a new extension is a field plus a constructor line, not another diff --git a/crates/rhema-claw/src/interactive.rs b/crates/rhema-claw/src/interactive.rs index b77a842..fd0a688 100644 --- a/crates/rhema-claw/src/interactive.rs +++ b/crates/rhema-claw/src/interactive.rs @@ -73,7 +73,12 @@ use crate::notifications::NotificationStore; /// `interactive:`, `task:`, `phone:`, and `calendar:`. Forge keys — /// they carry `#` — belong to the forge handler; these four and /// nothing else belong to this one. -pub const RESIDENT_PREFIXES: [&str; 4] = ["interactive:", "task:", "phone:", "calendar:"]; +pub const RESIDENT_PREFIXES: [&str; 4] = [ + "interactive:", + "task:", + "phone:", + rhema_calendar_integration::reminder::KEY_PREFIX, +]; /// The prefix of the live chat surface's keys: `interactive:` — /// the name is the caller's label for the session (`web`, a chat diff --git a/crates/rhema-claw/src/lib.rs b/crates/rhema-claw/src/lib.rs index 0bc68bb..efc02b9 100644 --- a/crates/rhema-claw/src/lib.rs +++ b/crates/rhema-claw/src/lib.rs @@ -49,8 +49,6 @@ //! the standard set — the tool chain, the detect passes, and the //! boot pass all iterate it //! - [`forge`] — the sources: one detectable stream per forge kind -//! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in, -//! events out //! - [`task_source`] — the [`TaskScheduler`]: due tasks in, events out //! - [`handler`] — the forge dispatch: snapshot, inbox, one turn, one //! comment @@ -69,7 +67,6 @@ pub mod accounts_tools; pub mod attachments; pub mod backends; pub mod bootstrap; -pub mod calendar; pub mod config; pub mod consents; pub mod daemon; @@ -103,7 +100,6 @@ pub use bootstrap::{ BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD, render_system, }; -pub use calendar::CalendarSource; pub use config::{ClawConfig, ConfigError, ForgeKind, Watch}; pub use daemon::{ClawError, Daemon, TickReport, run_once}; pub use environment::write_report; diff --git a/integrations/rhema-calendar-integration/Cargo.toml b/integrations/rhema-calendar-integration/Cargo.toml index afbd11b..c128750 100644 --- a/integrations/rhema-calendar-integration/Cargo.toml +++ b/integrations/rhema-calendar-integration/Cargo.toml @@ -12,6 +12,12 @@ authors.workspace = true publish = false [dependencies] +# The reminder source: one `Source` over the feeds, detect-pass shaped +# like the task scheduler's +rhema-autonomous-agent = { path = "../../crates/rhema-autonomous-agent", version = "0.1.0" } +# The reminder source ingests its slots into a `Dispatcher`; the event +# and key-function shapes are the dispatcher's +rhema-rsd = { path = "../../crates/rhema-rsd", version = "0.1.0" } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/integrations/rhema-calendar-integration/src/lib.rs b/integrations/rhema-calendar-integration/src/lib.rs index 76beeec..8995bde 100644 --- a/integrations/rhema-calendar-integration/src/lib.rs +++ b/integrations/rhema-calendar-integration/src/lib.rs @@ -3,16 +3,19 @@ //! 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. +//! An integration, not an agent: no driver, no model, and no loop +//! 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 default answer ships in [`crate::reminder`]: one +//! reminder slot per lead time, keyed `calendar:`, fed to the +//! consumer's dispatcher as a `Source` — every reminder-shaped +//! consumer starts there. A consumer with different semantics brings +//! its own; every [`crate::CalendarEvent`] carries an id that stays +//! the same across polls of the same occurrence, so any +//! implementation 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: @@ -57,6 +60,8 @@ //! - [`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) +//! - [`reminder`] — the default reminder source: due slots per lead +//! time, `calendar:` keys, one `Source` over the configured feeds #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -67,6 +72,7 @@ pub mod error; pub mod event; pub mod google; pub mod ics; +pub mod reminder; // Re-exported so implementors of the async seam can apply #[async_trait] // without adding the crate themselves. @@ -77,3 +83,4 @@ pub use error::CalendarError; pub use event::{CalendarEvent, Window}; pub use google::GoogleCalendar; pub use ics::IcsCalendar; +pub use reminder::{CalendarKind, CalendarSource, CalendarWatch, calendar_key}; diff --git a/crates/rhema-claw/src/calendar.rs b/integrations/rhema-calendar-integration/src/reminder.rs similarity index 67% rename from crates/rhema-claw/src/calendar.rs rename to integrations/rhema-calendar-integration/src/reminder.rs index 4284bda..08f5b45 100644 --- a/crates/rhema-claw/src/calendar.rs +++ b/integrations/rhema-calendar-integration/src/reminder.rs @@ -1,4 +1,4 @@ -//! The calendar source: deterministic detection of due reminders, the +//! The reminder source: deterministic detection of due reminders, the //! task scheduler's shape over a calendar feed. //! //! The [`CalendarSource`] is a [`Source`] named `"calendars"` on the @@ -13,33 +13,38 @@ //! never fire: a lead in minutes against a midnight start is noise, //! and a date-shaped nudge is what the task tools are for. //! -//! The model is never reached here; the turn happens because the -//! dispatcher admitted the `calendar:` key, exactly as for a -//! task firing, and the resident handler's delivery rule auto-notifies -//! the final text to the user's devices. One session per calendar -//! persists across its reminders, so the claw remembers what it said -//! about the last one. A dead feed is logged and skipped, never fatal: -//! an outage must not eat the other calendars' reminders on the same -//! pass. Cadence belongs to the daemon's loop. +//! This is the default reminder policy, shipped beside the feeds it +//! polls: one slot per lead time, `calendar:` keys, all-day +//! occurrences never fired. A consumer with different semantics brings +//! its own source — the vocabulary's stable occurrence ids keep any +//! implementation dedup-safe — but every reminder-shaped one starts +//! here. The model is never reached; the turn happens because the +//! consumer's dispatcher admitted the `calendar:` key. One +//! session per calendar persists across its reminders, so the resident +//! remembers what it said about the last one. A dead feed is logged +//! and skipped, never fatal: an outage must not eat the other +//! calendars' reminders on the same pass. Cadence belongs to the +//! consumer's loop. use std::sync::Arc; use rhema_autonomous_agent::{Source, SourceError, async_trait}; -use rhema_calendar_integration::{ - CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window, -}; -use rhema_rsd::{Clock, Dispatcher, Event, KeyFn}; -use serde_json::json; +use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, ResourceKey}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; -use crate::config::{CalendarKind, CalendarWatch}; -use crate::interactive::passthrough_key_fn; +use crate::{CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window}; /// The source name the calendar reminders ingest under; registered on -/// the resident dispatcher, so allowlists and `/v1/ingest` can name it. +/// the consumer's dispatcher, so allowlists and ingest doors can name +/// it. pub const CALENDARS_SOURCE: &str = "calendars"; +/// The key prefix the reminder sessions live under: `calendar:`. +pub const KEY_PREFIX: &str = "calendar:"; + /// The env variable a calendar's token falls back to. pub const TOKEN_ENV: &str = "CALENDAR_TOKEN"; @@ -49,7 +54,22 @@ pub const CALDAV_PASSWORD_ENV: &str = "CALDAV_PASSWORD"; /// One calendar's reminder key: `calendar:` — the session the /// reminder turns run in, persistent across the calendar's reminders. pub fn calendar_key(name: &str) -> String { - format!("calendar:{name}") + format!("{KEY_PREFIX}{name}") +} + +/// The key function the source registers: the payload's `key` +/// verbatim — one key, one owned session — with the infallible +/// fallback, so a keyless payload can neither clobber a session nor +/// wedge the dispatcher; it lands in the unroutable slot. +fn reminder_key_fn() -> Arc { + Arc::new(|event: &Event| { + event + .payload + .get("key") + .and_then(Value::as_str) + .map(ResourceKey::from) + .unwrap_or_else(|| ResourceKey::from(format!("{KEY_PREFIX}unroutable#{}", event.id))) + }) } /// One configured calendar, resolved to a client the source polls. @@ -238,7 +258,7 @@ impl CalendarSource { horizon_hours: calendar.horizon(), }); } - dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn()); + dispatcher.register_source(CALENDARS_SOURCE, reminder_key_fn()); Ok(Self { dispatcher: dispatcher.clone(), clock, @@ -254,7 +274,7 @@ impl CalendarSource { clock: Arc, feeds: Vec, ) -> Self { - dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn()); + dispatcher.register_source(CALENDARS_SOURCE, reminder_key_fn()); Self { dispatcher: dispatcher.clone(), clock, @@ -270,7 +290,7 @@ impl Source for CalendarSource { } fn key_fn(&self) -> Arc { - passthrough_key_fn() + reminder_key_fn() } async fn detect(&self) -> Result { @@ -289,7 +309,7 @@ impl Source for CalendarSource { // the next pass polls it again, and the other // calendars' reminders never wait behind it. Err(err) => { - eprintln!("[claw] warning: calendar {} failed: {err}", feed.name); + eprintln!("[calendar] warning: {} feed failed: {err}", feed.name); continue; } }; @@ -311,6 +331,7 @@ impl Source for CalendarSource { #[cfg(test)] mod tests { use super::*; + use crate::CalendarError; use rhema_rsd::{DispatcherConfig, FixedClock, ResourceKey, SystemClock, VolatileStore}; /// 2026-10-20T00:00:00Z, in milliseconds. @@ -332,10 +353,7 @@ mod tests { #[async_trait] impl Calendar for Fixed { - async fn events( - &self, - _window: &Window, - ) -> Result, rhema_calendar_integration::CalendarError> { + async fn events(&self, _window: &Window) -> Result, CalendarError> { Ok(self.0.clone()) } } @@ -462,13 +480,8 @@ mod tests { #[async_trait] impl Calendar for Dead { - async fn events( - &self, - _window: &Window, - ) -> Result, rhema_calendar_integration::CalendarError> { - Err(rhema_calendar_integration::CalendarError::Network( - "down".to_owned(), - )) + async fn events(&self, _window: &Window) -> Result, CalendarError> { + Err(CalendarError::Network("down".to_owned())) } } @@ -544,3 +557,172 @@ mod tests { assert!(prompt.contains("in 3 minutes"), "got: {prompt}"); } } + +/// The one reminder lead, in minutes, a calendar carries when +/// [`CalendarWatch::lead_minutes`] is unset: warn shortly before, and +/// let a busier lead list be configured where wanted. +pub const DEFAULT_LEAD_MINUTES: u64 = 15; + +/// How far ahead a calendar polls, in hours, when +/// [`CalendarWatch::horizon_hours`] is unset. Must exceed the longest +/// lead: a reminder cannot fire before the event is visible. +pub const DEFAULT_HORIZON_HOURS: u32 = 24; + +/// Which calendar feed a calendar watch polls. The wire spelling is +/// the source name the dispatcher registers and the reminder prompt +/// names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CalendarKind { + /// An iCalendar feed over HTTP (`webcal://` counts): Google's + /// secret iCal address, a Nextcloud or Radicale share, Fastmail. + Ics, + /// A CalDAV collection, polled with a `calendar-query` REPORT: + /// Nextcloud, Radicale, Fastmail, a plain DAV server. + Caldav, + /// The Google Calendar API, against an OAuth access token or an + /// API key for public calendars. + Google, +} + +impl CalendarKind { + /// The spelling the config, the reminder source, and the prompts + /// use. + pub fn name(self) -> &'static str { + match self { + CalendarKind::Ics => "ics", + CalendarKind::Caldav => "caldav", + CalendarKind::Google => "google", + } + } + + /// Parses a calendar kind as the config spells it. `None` for + /// anything else. + pub fn parse(name: &str) -> Option { + match name.trim() { + "ics" => Some(CalendarKind::Ics), + "caldav" => Some(CalendarKind::Caldav), + "google" => Some(CalendarKind::Google), + _ => None, + } + } +} + +/// One watched calendar. Unknown keys are rejected, as on a watch. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct CalendarWatch { + /// The calendar's name: unique across the list; the reminder key + /// is `calendar:` and prompts name the calendar by it. + pub name: String, + /// Which feed kind this polls. + pub kind: CalendarKind, + /// The feed URL (`https://` or `webcal://`). Required for ics. + pub url: Option, + /// The Google calendar id (`primary`, an address, a hex id). + /// Required for google. + pub calendar_id: Option, + /// A bearer token (an OAuth access token for Google, auth for a + /// feed or collection that demands it). Falls back to + /// `CALENDAR_TOKEN`. + pub token: Option, + /// The CalDAV user, for Basic auth. Set only with a password (or + /// `CALDAV_PASSWORD`); a calendar with a username authenticates + /// Basic, and `token` is the fallback only when there is none. + pub username: Option, + /// The CalDAV password or app-specific password. Falls back to + /// `CALDAV_PASSWORD`. + pub password: Option, + /// A Google API key for a public calendar; wins over `token`. + pub api_key: Option, + /// Reminder lead times, minutes; each occurrence warns once per + /// lead. `[DEFAULT_LEAD_MINUTES]` when unset. + pub lead_minutes: Option>, + /// How far ahead the poll looks, hours. `[DEFAULT_HORIZON_HOURS]` + /// when unset; must exceed the longest lead. + pub horizon_hours: Option, +} + +impl CalendarWatch { + /// The reminder leads as configured, blank and zero entries + /// dropped; [`DEFAULT_LEAD_MINUTES`] alone when nothing usable is + /// configured. + pub fn leads(&self) -> Vec { + match self.lead_minutes.as_ref() { + Some(leads) => { + let usable: Vec = leads.iter().copied().filter(|lead| *lead > 0).collect(); + if usable.is_empty() { + vec![DEFAULT_LEAD_MINUTES] + } else { + usable + } + } + None => vec![DEFAULT_LEAD_MINUTES], + } + } + + /// The poll horizon as configured, [`DEFAULT_HORIZON_HOURS`] when + /// unset. Zero reads as the default: a horizon of nothing polls + /// nothing, and the config validation refuses it when it would + /// matter. + pub fn horizon(&self) -> u32 { + self.horizon_hours + .filter(|hours| *hours > 0) + .unwrap_or(DEFAULT_HORIZON_HOURS) + } + + /// A trimmed `token`/`api_key` value; blank counts as unset. + fn trimmed(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + } + + /// The API key as configured, if any. + pub fn resolved_api_key(&self) -> Option { + Self::trimmed(self.api_key.as_deref()) + } + + /// The CalDAV password to authenticate with: the config value, + /// then `CALDAV_PASSWORD`. Parameterized over the env lookup in + /// the pure [`CalendarWatch::resolve_password_with`]. + pub fn resolved_password(&self) -> Option { + Self::resolve_password_with(|name| std::env::var(name), self.password.as_deref()) + } + + /// Pure password resolution: the config value first, then + /// `CALDAV_PASSWORD`. Parameterized over the env lookup so tests + /// can hold the environment still. + pub fn resolve_password_with( + get_env: impl Fn(&str) -> Result, + stored: Option<&str>, + ) -> Option { + Self::trimmed(stored).or_else(|| { + get_env("CALDAV_PASSWORD") + .ok() + .and_then(|token| Self::trimmed(Some(&token))) + }) + } + + /// The token to poll with: the config value, then `CALENDAR_TOKEN`; + /// the first non-blank wins. Parameterized over the env lookup in + /// the pure [`CalendarWatch::resolve_token_with`]. + pub fn resolved_token(&self) -> Option { + Self::resolve_token_with(|name| std::env::var(name), self.token.as_deref()) + } + + /// Pure token resolution: the config value first, then + /// `CALENDAR_TOKEN`. Parameterized over the env lookup so tests can + /// hold the environment still. + pub fn resolve_token_with( + get_env: impl Fn(&str) -> Result, + stored: Option<&str>, + ) -> Option { + Self::trimmed(stored).or_else(|| { + get_env("CALENDAR_TOKEN") + .ok() + .and_then(|token| Self::trimmed(Some(&token))) + }) + } +}