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:
parent
838e7a1891
commit
06e661b0c4
4 changed files with 213 additions and 3 deletions
3
TODO.md
3
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
|
||||
|
||||
|
|
|
|||
32
src/main.rs
32
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::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
158
src/status.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue