Files
runway/crates/runway-cli/src/main.rs
T
connor c197e08af0 Default the CLI to the system time zone
It shipped with America/Denver hardcoded, taken from what dominates the
historical data on the server. That data is old; the reader has moved.
A zone belongs to whoever is looking at the calendar, and baking one in is
the same mistake as v1's offset-instead-of-zone in miniature -- it looks
right until the reader is somewhere else.

Falls back to UTC rather than to a populated guess: an obviously neutral
wrong answer gets noticed, a plausible one does not.
2026-08-26 15:38:55 -04:00

249 lines
7.7 KiB
Rust

//! 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.
///
/// Defaults to whatever the machine is set to. Baking in a zone would be
/// the same mistake in miniature that this project exists to correct: the
/// right zone is a property of the reader, and readers move.
#[arg(long, default_value_t = local_timezone())]
timezone: Tz,
#[command(subcommand)]
command: Command,
}
/// The system's own IANA zone, falling back to UTC when it cannot be read.
///
/// UTC rather than a guess at somewhere populated: a wrong zone that looks
/// plausible is worse than an obviously neutral one, because nobody checks it.
fn local_timezone() -> Tz {
iana_time_zone::get_timezone()
.ok()
.and_then(|name| name.parse().ok())
.unwrap_or(Tz::UTC)
}
#[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>()
+ "…"
}