Add calcalist status, and make routing markers discoverable

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 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 13:40:27 +03:00
parent 838e7a1891
commit 06e661b0c4
4 changed files with 213 additions and 3 deletions

View file

@ -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

View file

@ -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::<Vec<_>>()
.join(", ")
),
}
}

View file

@ -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<String>,
},
}
@ -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<String> {
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()],
}]
);
}

158
src/status.rs Normal file
View file

@ -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<String, StateError> {
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}");
}
}