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:
2026-08-26 15:32:08 -04:00
parent 689b78154b
commit f8e4a497fa
20 changed files with 2893 additions and 3 deletions
+232 -2
View File
@@ -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>()
+ ""
}