Add the CalDAV client and a CLI to drive it
Discovery is the three PROPFINDs RFC 4791 describes rather than a walk through likely URLs. Queries use time-range, which v1 never did -- it fetched whole calendars and filtered in the browser on every view change. Every write states a precondition, so a stale ETag produces a Conflict a caller can act on instead of silently destroying somebody's edit. XML goes through quick-xml with namespace resolution. v1 matched prefixes with six regexes tried in sequence and recompiled inside the loop; there is a fixture here that is the same document under different prefixes, and it parses identically. Protocol parsing is split from transport so it can be tested against responses recorded from a real Baikal -- including the second propstat carrying 404s, which is what makes "this calendar has no colour" different from "this calendar has an empty colour". Live tests run against a real server, never a mock. tests/baikal/run.sh starts a container, walks Baikal's install wizard, and runs them; each test builds and destroys its own collection, so pointing it at a real server touches nothing that was already there. They cover discovery, round-trip, stale-ETag conflict, duplicate create, delete, time-range filtering, a series returned whole with its override, and writing every synthetic golden fixture to the server and reading it back. libdav was evaluated first, as planned. Not adopted: its HttpClient trait is defined over hyper::body::Incoming, so using it means replacing reqwest everywhere, plus a DNS resolver for service discovery we do not do and a second XML parser. Its precondition design is where Precondition's shape comes from. Reasons are recorded in the crate docs.
This commit is contained in:
@@ -9,6 +9,8 @@ description = "Smoke tool for exercising a real CalDAV server from the terminal.
|
||||
runway-core = { workspace = true, features = ["ical", "recurrence"] }
|
||||
runway-caldav = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,233 @@
|
||||
fn main() {
|
||||
println!("runway-cli: not yet implemented");
|
||||
//! A terminal tool for pointing Runway's own stack at a real CalDAV server.
|
||||
//!
|
||||
//! Deliberately thin: it holds no logic of its own, so what it prints is what
|
||||
//! `runway-caldav` fetched and what `runway-core` expanded. When a calendar
|
||||
//! looks wrong in the browser, this is how to find out whether the problem is
|
||||
//! in the view or underneath it — a question the previous iteration could only
|
||||
//! answer by adding `println!`s to the backend and redeploying.
|
||||
|
||||
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use clap::{Parser, Subcommand};
|
||||
use runway_caldav::{CalDavClient, Credentials};
|
||||
use runway_core::model::Occurrence;
|
||||
use runway_core::recurrence::{Window, Zones, expand, unresolved_zones};
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "runway",
|
||||
about = "Talk to a CalDAV server the way Runway does."
|
||||
)]
|
||||
struct Cli {
|
||||
/// The DAV root, e.g. https://example.com/dav.php/
|
||||
#[arg(long, env = "RUNWAY_CALDAV_URL")]
|
||||
server: String,
|
||||
|
||||
#[arg(long, env = "RUNWAY_CALDAV_USER")]
|
||||
user: String,
|
||||
|
||||
/// Read from the environment rather than the command line, so it does not
|
||||
/// end up in shell history or in the process list.
|
||||
#[arg(long, env = "RUNWAY_CALDAV_PASSWORD", hide_env_values = true)]
|
||||
password: String,
|
||||
|
||||
/// The zone to show times in, and to read floating times as.
|
||||
#[arg(long, default_value = "America/Denver")]
|
||||
timezone: Tz,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// List the calendars the server offers.
|
||||
Calendars,
|
||||
|
||||
/// List occurrences in a date range, expanded.
|
||||
ListEvents {
|
||||
/// Inclusive start date, YYYY-MM-DD.
|
||||
#[arg(long)]
|
||||
from: NaiveDate,
|
||||
/// Exclusive end date, YYYY-MM-DD.
|
||||
#[arg(long)]
|
||||
to: NaiveDate,
|
||||
/// Only this calendar, matched on href or display name. Repeatable.
|
||||
#[arg(long = "calendar")]
|
||||
calendars: Vec<String>,
|
||||
},
|
||||
|
||||
/// Print the raw iCalendar for one resource, as the server stores it.
|
||||
Show {
|
||||
/// The href of the resource.
|
||||
href: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "runway_cli=info,runway_caldav=info".into()),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match run(cli).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = CalDavClient::new(&cli.server, Credentials::new(&cli.user, cli.password))?;
|
||||
|
||||
match cli.command {
|
||||
Command::Calendars => list_calendars(&client).await,
|
||||
Command::ListEvents {
|
||||
from,
|
||||
to,
|
||||
calendars,
|
||||
} => list_events(&client, from, to, &calendars, cli.timezone).await,
|
||||
Command::Show { href } => show(&client, &href).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_calendars(client: &CalDavClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let calendars = client.discover().await?;
|
||||
if calendars.is_empty() {
|
||||
println!("no calendars on this server");
|
||||
return Ok(());
|
||||
}
|
||||
for calendar in &calendars {
|
||||
let colour = calendar.color.as_deref().unwrap_or("—");
|
||||
println!("{:<28} {:<9} {}", calendar.name(), colour, calendar.href);
|
||||
if !calendar.supports_events() {
|
||||
println!("{:<28} (holds no events)", "");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_events(
|
||||
client: &CalDavClient,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
wanted: &[String],
|
||||
timezone: Tz,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let zones = Zones::new(timezone);
|
||||
let window = Window::new(midnight(timezone, from), midnight(timezone, to));
|
||||
|
||||
let calendars: Vec<_> = client
|
||||
.discover()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|c| c.supports_events())
|
||||
.filter(|c| wanted.is_empty() || wanted.iter().any(|w| matches(c, w)))
|
||||
.collect();
|
||||
|
||||
if calendars.is_empty() {
|
||||
return Err("no calendar matched".into());
|
||||
}
|
||||
|
||||
let mut rows: Vec<(String, Occurrence)> = Vec::new();
|
||||
for calendar in &calendars {
|
||||
let objects = client
|
||||
.events_in_range(&calendar.href, window.from, window.to)
|
||||
.await?;
|
||||
|
||||
for object in &objects {
|
||||
// Reported, not swallowed: a zone we could not identify means the
|
||||
// times below may be wrong by hours.
|
||||
for zone in unresolved_zones(&object.calendar, zones) {
|
||||
eprintln!("warning: {} names an unknown time zone {zone}", object.href);
|
||||
}
|
||||
for occurrence in expand(&object.calendar, window, zones)? {
|
||||
rows.push((calendar.name().to_owned(), occurrence));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort_by_key(|(_, occurrence)| occurrence.start_utc);
|
||||
|
||||
println!(
|
||||
"{} occurrence(s) between {from} and {to}, shown in {timezone}",
|
||||
rows.len(),
|
||||
);
|
||||
for (calendar, occurrence) in &rows {
|
||||
println!(
|
||||
"{:<10} {:<13} {:<16} {}",
|
||||
occurrence.start.date(),
|
||||
span(occurrence, timezone),
|
||||
truncate(calendar, 16),
|
||||
label(occurrence),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show(client: &CalDavClient, href: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let calendar_path = href.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
|
||||
let object = client
|
||||
.get_object(&format!("{calendar_path}/"), href)
|
||||
.await?;
|
||||
if let Some(etag) = &object.etag {
|
||||
eprintln!("etag: {etag}");
|
||||
}
|
||||
print!("{}", runway_core::ical::write(&object.calendar));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches(calendar: &runway_caldav::Calendar, wanted: &str) -> bool {
|
||||
calendar.href.contains(wanted) || calendar.name().eq_ignore_ascii_case(wanted)
|
||||
}
|
||||
|
||||
fn midnight(tz: Tz, date: NaiveDate) -> DateTime<Utc> {
|
||||
let local = date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
runway_core::recurrence::instant_in(tz, local)
|
||||
}
|
||||
|
||||
/// The time column: a clock range, or the fact that there is not one.
|
||||
fn span(occurrence: &Occurrence, tz: Tz) -> String {
|
||||
if occurrence.is_all_day() {
|
||||
return "all-day".to_owned();
|
||||
}
|
||||
let clock = |instant: DateTime<Utc>| {
|
||||
tz.from_utc_datetime(&instant.naive_utc())
|
||||
.format("%H:%M")
|
||||
.to_string()
|
||||
};
|
||||
format!(
|
||||
"{}-{}",
|
||||
clock(occurrence.start_utc),
|
||||
clock(occurrence.end_utc)
|
||||
)
|
||||
}
|
||||
|
||||
fn label(occurrence: &Occurrence) -> String {
|
||||
let title = occurrence.title().unwrap_or("(untitled)");
|
||||
if occurrence.is_override {
|
||||
format!("{title} [moved]")
|
||||
} else if occurrence.recurrence_id.is_some() {
|
||||
format!("{title} [recurring]")
|
||||
} else {
|
||||
title.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(text: &str, width: usize) -> String {
|
||||
if text.chars().count() <= width {
|
||||
return text.to_owned();
|
||||
}
|
||||
text.chars()
|
||||
.take(width.saturating_sub(1))
|
||||
.collect::<String>()
|
||||
+ "…"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user