From 06e661b0c41f020144487cc29602541e69a0d225 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 10 Sep 2026 13:40:27 +0300 Subject: [PATCH] Add calcalist status, and make routing markers discoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker that steers a new event is the endpoint's id, which until now meant remembering what was written in a TOML file while typing into a calendar app. status lists the endpoints, what each aggregate publishes and mirrors, where untagged events go, and the exact @markers that would work — read-only sources omitted, since offering one as a destination would mislead. A refused marker now names the alternatives rather than only reporting that it failed, and matching ignores case: the name is typed by hand, and capitalisation is not worth failing over. 133 tests. Co-Authored-By: Claude Opus 5 --- TODO.md | 3 + src/main.rs | 32 +++++++++- src/reconcile.rs | 23 ++++++- src/status.rs | 158 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/status.rs diff --git a/TODO.md b/TODO.md index 8bc472a..266842f 100644 --- a/TODO.md +++ b/TODO.md @@ -58,6 +58,9 @@ integration tests, which are listed with the remaining gaps at the end. - [x] `webcal` URLs may come from a command, for feeds whose address is itself a credential — Google's secret iCal address being the case in point - [x] `doctor` validates the generated pimsync config and each Google authorisation +- [x] `calcalist status` — endpoints, aggregates, how many events are mirrored, and the + exact `@marker` names an event can carry, so routing is discoverable without + opening the config file ### Tests diff --git a/src/main.rs b/src/main.rs index 1aee63a..3bb5c2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod provenance; mod reconcile; mod retarget; mod state; +mod status; mod sync; mod vdir; @@ -30,6 +31,10 @@ fn main() -> ExitCode { command: Command::Doctor, .. } => run_doctor(cli), + cli @ Cli { + command: Command::Status, + .. + } => run_status(cli), cli @ Cli { command: Command::Sync { dry_run, force }, .. @@ -105,6 +110,25 @@ fn run_google_login(cli: &Cli, endpoint_id: &str) -> ExitCode { } } +/// Shows what is configured, and what an event's notes may say to steer it. +fn run_status(cli: &Cli) -> ExitCode { + let (config, _) = match Config::load(cli.config.as_deref()) { + Ok(loaded) => loaded, + Err(error) => return fail(&error), + }; + let state_dir = match paths::state_dir() { + Ok(dir) => dir, + Err(error) => return fail(&error), + }; + match status::render(&config, &state_dir) { + Ok(report) => { + print!("{report}"); + ExitCode::SUCCESS + } + Err(error) => fail(&error), + } +} + /// Moves an aggregate to a different target endpoint, deliberately. fn run_retarget(cli: &Cli, id: &str, to: &str, purge_old: bool) -> ExitCode { let (config, _) = match Config::load(cli.config.as_deref()) { @@ -202,8 +226,14 @@ fn describe(skipped: &reconcile::Skipped) -> String { reconcile::Skipped::UnknownSink { aggregate_uid, requested, + available, } => format!( - "`{aggregate_uid}` asked to be filed under `{requested}`, which is not a writable source of this aggregate; it was left where it is" + "`{aggregate_uid}` asked to be filed under `@{requested}`, which is not a writable source of this aggregate. It was left where it is. Try one of: {}", + available + .iter() + .map(|name| format!("@{name}")) + .collect::>() + .join(", ") ), } } diff --git a/src/reconcile.rs b/src/reconcile.rs index 6ac7a07..4eb4635 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -93,10 +93,12 @@ pub enum Skipped { }, /// The event asked to be filed under a source that cannot take it. Refused /// rather than sent to the default, since a typo should not quietly put the - /// event in the wrong calendar. + /// event in the wrong calendar. The valid names travel with it, so the report + /// can say what would have worked. UnknownSink { aggregate_uid: String, requested: String, + available: Vec, }, } @@ -359,8 +361,14 @@ fn route_new_events( // configured sink takes it. let requested = mirror::routing_hint(&item.calendar); let wanted = requested.as_deref().or(policy.default_sink); + // Case-insensitively: the name is typed into a calendar app by hand, and + // capitalisation is not worth failing over. let sink = wanted - .and_then(|sink| sources.iter().find(|source| source.id == sink)) + .and_then(|sink| { + sources + .iter() + .find(|source| source.id.eq_ignore_ascii_case(sink)) + }) .filter(|sink| sink.writable); let Some(sink) = sink else { @@ -368,6 +376,7 @@ fn route_new_events( Some(requested) => Skipped::UnknownSink { aggregate_uid: uid.clone(), requested, + available: writable_sinks(sources), }, None => Skipped::NoSink { aggregate_uid: uid.clone(), @@ -431,6 +440,15 @@ fn route_new_events( /// that failed to populate, which shows up as a *bulk* disappearance. const ALWAYS_ALLOWED_DELETIONS: usize = 3; +/// The sources of this aggregate that an event could actually be filed under. +fn writable_sinks(sources: &[SourceView<'_>]) -> Vec { + sources + .iter() + .filter(|source| source.writable) + .map(|source| source.id.to_string()) + .collect() +} + /// Refuses a cycle that would delete an implausible share of the aggregate. /// /// A source vdir that failed to populate looks exactly like one whose events were @@ -826,6 +844,7 @@ mod tests { vec![Skipped::UnknownSink { aggregate_uid: "hand@phone".into(), requested: "nosuchplace".into(), + available: vec![SRC.to_string()], }] ); } diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..e13d427 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,158 @@ +//! `calcalist status` — what is configured, and what it has done. +//! +//! Its most practical job is naming the markers an event can carry. A routing +//! hint is typed by hand into a calendar app, and having to remember what was +//! written in a TOML file is no way to find out what it should say. + +use std::fmt::Write as _; +use std::path::Path; + +use crate::config::{Aggregate, Config}; +use crate::state::{State, StateError}; + +/// Renders the report shown by `calcalist status`. +pub fn render(config: &Config, state_dir: &Path) -> Result { + let state = State::load(&state_dir.join(crate::state::FILE_NAME))?; + let mut out = String::new(); + + out.push_str("endpoints\n"); + for endpoint in &config.endpoints { + let access = if endpoint.kind.is_writable() { + "read/write" + } else { + "read-only" + }; + let _ = writeln!( + out, + " {:<18} {:<8} {access}", + endpoint.id, + endpoint.kind.kind_name() + ); + } + + for aggregate in &config.aggregates { + let _ = write!(out, "\naggregate `{}`\n", aggregate.id); + let _ = writeln!(out, " published to {}", aggregate.target); + let _ = writeln!(out, " sources {}", aggregate.sources.join(", ")); + + let tracked = state + .aggregate(&aggregate.id) + .map_or(0, |entry| entry.links.len()); + let _ = writeln!(out, " mirroring {tracked} event(s)"); + + describe_routing(&mut out, config, aggregate); + } + + if config.aggregates.is_empty() { + out.push_str("\nno aggregates configured\n"); + } + Ok(out) +} + +/// Explains where a newly created event goes, and how to send it elsewhere. +fn describe_routing(out: &mut String, config: &Config, aggregate: &Aggregate) { + let sinks: Vec<&str> = aggregate + .sources + .iter() + .filter(|id| { + config + .endpoint(id) + .is_some_and(|endpoint| endpoint.kind.is_writable()) + }) + .map(String::as_str) + .collect(); + + match &aggregate.default_sink { + Some(sink) => { + let _ = writeln!(out, " new events go to `{sink}` unless told otherwise"); + } + None => { + let _ = writeln!( + out, + " new events are left alone: no default_sink is configured" + ); + } + } + if sinks.is_empty() { + let _ = writeln!(out, " none of its sources can be written to"); + return; + } + let _ = writeln!( + out, + " to choose, put one of these on a line of its own in the event's notes:" + ); + for sink in sinks { + let _ = writeln!(out, " @{sink}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CONFIG: &str = r#" +version = 1 + +[[endpoint]] +id = "gcal" +type = "google" +calendar_id = "a@example.com" +client_id = "x.apps.googleusercontent.com" + +[[endpoint]] +id = "posteo" +type = "caldav" +url = "https://dav.example.com/dav/calendars/me/work/" +username = "me@example.com" + +[[endpoint]] +id = "holidays" +type = "webcal" +url = "https://example.org/h.ics" + +[[aggregate]] +id = "unified" +target = "posteo" +sources = ["gcal", "holidays"] +default_sink = "gcal" +"#; + + fn report() -> String { + let config: Config = toml::from_str(CONFIG).expect("config"); + let dir = tempfile::tempdir().expect("temp dir"); + render(&config, dir.path()).expect("render") + } + + /// The whole point of the command: knowing what to type without opening the + /// configuration file. + #[test] + fn it_names_the_markers_that_would_work() { + let report = report(); + assert!(report.contains("@gcal"), "{report}"); + } + + /// A read-only feed cannot take an event, so offering it would mislead. + #[test] + fn it_does_not_offer_a_read_only_source_as_a_destination() { + let report = report(); + assert!(!report.contains("@holidays"), "{report}"); + assert!( + report.contains("holidays"), + "it should still be listed as a source" + ); + } + + #[test] + fn it_says_where_untagged_events_go() { + assert!(report().contains("new events go to `gcal`")); + } + + #[test] + fn it_says_when_nothing_would_be_routed() { + let sinkless: Config = + toml::from_str(&CONFIG.replace(r#"default_sink = "gcal""#, "")).expect("config"); + let dir = tempfile::tempdir().expect("temp dir"); + let report = render(&sinkless, dir.path()).expect("render"); + assert!(report.contains("no default_sink is configured"), "{report}"); + } +}