mod commands;
mod output;
mod utils;
use anyhow::{Result, bail};
use caldir_core::Caldir;
use clap::{Parser, Subcommand};
use output::OutputFormat;
#[derive(Parser)]
#[command(name = "caldir-cli")]
#[command(version)]
#[command(about = "Interact with your caldir events and sync to remote calendars")]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true, value_parser = humantime::parse_duration)]
timeout: Option<std::time::Duration>,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "Connect to a remote calendar provider (e.g., Google Calendar)")]
Connect {
provider: Option<String>,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
hosted: bool,
},
#[command(about = "Check if any events have changed (local and remote)")]
Status {
#[arg(short, long)]
calendar: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(short, long)]
verbose: bool,
},
#[command(about = "Pull changes from remote calendars into local caldir")]
Pull {
#[arg(short, long)]
calendar: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(short, long)]
verbose: bool,
},
#[command(about = "Push changes from local caldir to remote calendars")]
Push {
#[arg(short, long)]
calendar: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
force: bool,
},
#[command(about = "Sync changes between caldir and remote calendars (push + pull)")]
Sync {
#[arg(short, long)]
calendar: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
force: bool,
},
#[command(about = "List upcoming events across all calendars")]
Events {
#[arg(short, long)]
calendar: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
},
#[command(about = "Show today's events")]
Today {
#[arg(short, long)]
calendar: Option<String>,
},
#[command(about = "Show this week's events (through Sunday)")]
Week {
#[arg(short, long)]
calendar: Option<String>,
},
#[command(about = "Create a new event in caldir")]
New {
title: Option<String>,
#[arg(short, long)]
start: Option<String>,
#[arg(short, long)]
end: Option<String>,
#[arg(short, long)]
duration: Option<String>,
#[arg(short, long)]
location: Option<String>,
#[arg(short = 'C', long)]
calendar: Option<String>,
#[arg(short, long, conflicts_with = "no_reminders")]
reminder: Vec<String>,
#[arg(long)]
no_reminders: bool,
},
#[command(about = "Discard unpushed local changes (restore to remote state)")]
Discard {
#[arg(short, long)]
calendar: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
force: bool,
},
#[command(about = "List pending invites across calendars")]
Invites {
#[arg(short, long)]
calendar: Option<String>,
#[arg(short, long)]
all: bool,
},
#[command(about = "Respond to a calendar invite")]
Rsvp {
path: Option<String>,
response: Option<String>,
},
#[command(about = "Show configuration paths and calendar info")]
Config,
#[command(about = "List configured calendars")]
Calendars,
#[command(about = "Check your caldir for bad data (e.g. duplicate files)")]
Doctor,
#[command(about = "Update caldir and installed providers to the latest version")]
Update,
}
impl Commands {
fn supports_json(&self) -> bool {
matches!(
self,
Self::Calendars
| Self::Config
| Self::Events { .. }
| Self::Invites { .. }
| Self::Today { .. }
| Self::Week { .. }
)
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.json && !cli.command.supports_json() {
bail!("--json is not yet supported for this command");
}
let output_format = if cli.json {
OutputFormat::Json
} else {
OutputFormat::Text
};
if let Commands::Update = cli.command {
return commands::update::run().await;
}
let mut caldir = Caldir::load()?;
if let Some(timeout) = cli.timeout {
caldir.set_provider_timeout(timeout);
}
match cli.command {
Commands::Connect { provider, hosted } => {
commands::connect::run(&mut caldir, provider, hosted).await
}
Commands::Status {
calendar,
from,
to,
verbose,
} => commands::status::run(&caldir, calendar, from, to, verbose).await,
Commands::Pull {
calendar,
from,
to,
verbose,
} => commands::pull::run(&caldir, calendar.into_iter().collect(), from, to, verbose).await,
Commands::Push {
calendar,
from,
to,
verbose,
force,
} => commands::push::run(&caldir, calendar, from, to, verbose, force).await,
Commands::Sync {
calendar,
from,
to,
verbose,
force,
} => commands::sync::run(&caldir, calendar, from, to, verbose, force).await,
Commands::Events { calendar, from, to } => {
let view = commands::events::run(&caldir, calendar, from, to)?;
output::emit(&view, output_format);
Ok(())
}
Commands::Today { calendar } => {
let view = commands::today::run(&caldir, calendar)?;
output::emit(&view, output_format);
Ok(())
}
Commands::Week { calendar } => {
let view = commands::week::run(&caldir, calendar)?;
output::emit(&view, output_format);
Ok(())
}
Commands::New {
title,
start,
end,
duration,
location,
calendar,
reminder,
no_reminders,
} => commands::new::run(
&caldir,
title,
start,
end,
duration,
location,
calendar,
reminder,
no_reminders,
),
Commands::Discard {
calendar,
from,
to,
verbose,
force,
} => commands::discard::run(&caldir, calendar, from, to, verbose, force).await,
Commands::Invites { calendar, all } => {
let view = commands::invites::run(&caldir, calendar, all)?;
output::emit(&view, output_format);
Ok(())
}
Commands::Rsvp { path, response } => commands::rsvp::run(&caldir, path, response),
Commands::Config => {
let view = commands::config::run(&caldir)?;
output::emit(&view, output_format);
Ok(())
}
Commands::Calendars => {
let view = commands::calendars::run(&caldir)?;
output::emit(&view, output_format);
Ok(())
}
Commands::Doctor => commands::doctor::run(&caldir),
Commands::Update => unreachable!("handled above"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse_hosted(args: &[&str]) -> bool {
match Cli::parse_from(args).command {
Commands::Connect { hosted, .. } => hosted,
_ => panic!("expected connect command"),
}
}
#[test]
fn hosted_flag_parses_explicit_values_and_defaults_to_true() {
assert!(!parse_hosted(&[
"caldir",
"connect",
"--hosted=false",
"google"
]));
assert!(parse_hosted(&[
"caldir",
"connect",
"--hosted=true",
"google"
]));
assert!(parse_hosted(&["caldir", "connect", "google"]));
}
#[test]
fn timeout_parses_human_readable_duration() {
let cli = Cli::parse_from(["caldir", "status", "--timeout", "30s"]);
assert_eq!(cli.timeout, Some(std::time::Duration::from_secs(30)));
}
#[test]
fn timeout_defaults_to_none() {
let cli = Cli::parse_from(["caldir", "status"]);
assert_eq!(cli.timeout, None);
}
#[test]
fn views_support_json() {
assert!(Commands::Calendars.supports_json());
assert!(Commands::Config.supports_json());
assert!(
Commands::Events {
calendar: None,
from: None,
to: None,
}
.supports_json()
);
assert!(Commands::Today { calendar: None }.supports_json());
assert!(Commands::Week { calendar: None }.supports_json());
assert!(
Commands::Invites {
calendar: None,
all: false,
}
.supports_json()
);
assert!(!Commands::Update.supports_json());
}
}