//! 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, }, /// 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> { 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> { 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> { 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> { 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 { 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| { 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::() + "…" }