mod commands;
mod render;
mod utils;
use anyhow::Result;
use caldir_core::caldir::Caldir;
use caldir_core::calendar::Calendar;
use caldir_core::date_range::DateRange;
use caldir_core::remote::provider::Provider;
use chrono::{Datelike, Local, Utc};
use clap::{Parser, Subcommand};
use utils::date::start_of_today;
#[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,
}
#[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)]
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(short, long)]
verbose: 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,
},
#[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(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 invites")]
Rsvp {
path: Option<String>,
response: Option<String>,
},
#[command(about = "Show configuration paths and calendar info")]
Config,
#[command(about = "Update caldir and installed providers to the latest version")]
Update,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Connect { provider, hosted } => {
let provider = validate_provider(provider)?;
commands::connect::run(&provider, hosted).await
}
Commands::Status {
calendar,
from,
to,
verbose,
} => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
let range = DateRange::from_args(from.as_deref(), to.as_deref())
.map_err(|e| anyhow::anyhow!(e))?;
commands::status::run(calendars, range, verbose).await
}
Commands::Pull {
calendar,
from,
to,
verbose,
} => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
let range = DateRange::from_args(from.as_deref(), to.as_deref())
.map_err(|e| anyhow::anyhow!(e))?;
commands::pull::run(calendars, range, verbose).await
}
Commands::Push { calendar, verbose } => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
commands::push::run(calendars, verbose).await
}
Commands::Sync {
calendar,
from,
to,
verbose,
} => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
let range = DateRange::from_args(from.as_deref(), to.as_deref())
.map_err(|e| anyhow::anyhow!(e))?;
commands::sync::run(calendars, range, verbose).await
}
Commands::Events { calendar, from, to } => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
use caldir_core::date_range::{parse_date_end, parse_date_start};
let from_dt = from
.as_deref()
.map(parse_date_start)
.transpose()
.map_err(|e| anyhow::anyhow!(e))?;
let to_dt = to
.as_deref()
.map(parse_date_end)
.transpose()
.map_err(|e| anyhow::anyhow!(e))?;
commands::events::run(calendars, from_dt, to_dt)
}
Commands::Today { calendar } => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
let today = Local::now().date_naive();
let end_of_today = today
.and_hms_opt(23, 59, 59)
.unwrap()
.and_local_timezone(Local)
.unwrap()
.with_timezone(&Utc);
commands::events::run(calendars, Some(start_of_today()), Some(end_of_today))
}
Commands::Week { calendar } => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
let today = Local::now().date_naive();
let days_until_sunday = (6 - today.weekday().num_days_from_monday()) % 7;
let days_until_sunday = if days_until_sunday == 0 {
7
} else {
days_until_sunday
};
let end_of_sunday = (today + chrono::Duration::days(days_until_sunday as i64))
.and_hms_opt(23, 59, 59)
.unwrap()
.and_local_timezone(Local)
.unwrap()
.with_timezone(&Utc);
commands::events::run(calendars, Some(start_of_today()), Some(end_of_sunday))
}
Commands::New {
title,
start,
end,
duration,
location,
calendar,
reminder,
no_reminders,
} => {
require_calendars()?;
let calendars = resolve_calendars(None)?;
commands::new::run(
title,
start,
end,
duration,
location,
calendar,
reminder,
no_reminders,
calendars,
)
}
Commands::Discard {
calendar,
verbose,
force,
} => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
commands::discard::run(calendars, verbose, force).await
}
Commands::Invites { calendar, all } => {
require_calendars()?;
let calendars = resolve_calendars(calendar.as_deref())?;
commands::invites::run(calendars, all)
}
Commands::Rsvp { path, response } => {
require_calendars()?;
commands::rsvp::run(path, response)
}
Commands::Config => commands::config::run(),
Commands::Update => commands::update::run().await,
}
}
fn validate_provider(provider: Option<String>) -> Result<String> {
let name = match provider {
Some(name) => name,
None => {
let installed = Provider::discover_installed();
if installed.is_empty() {
anyhow::bail!(
"Missing provider argument.\n\n\
Usage: caldir connect <provider>\n\n\
No providers detected. Install one with:\n \
cargo install caldir-provider-google"
);
} else {
anyhow::bail!(
"Missing provider argument.\n\n\
Usage: caldir connect <provider>\n\n\
Detected providers: {}",
installed
.iter()
.map(|p| format!("\"{}\"", p))
.collect::<Vec<_>>()
.join(", ")
);
}
}
};
let installed = Provider::discover_installed();
if !installed.contains(&name) {
if installed.is_empty() {
anyhow::bail!(
"Unknown provider \"{name}\".\n\n\
No providers detected. Install one with:\n \
cargo install caldir-provider-{name}"
);
} else {
anyhow::bail!(
"Unknown provider \"{name}\".\n\n\
Detected providers: {}",
installed
.iter()
.map(|p| format!("\"{}\"", p))
.collect::<Vec<_>>()
.join(", ")
);
}
}
Ok(name)
}
fn require_calendars() -> Result<()> {
let caldir = Caldir::load()?;
if caldir.calendars().is_empty() {
anyhow::bail!(
"No calendars found.\n\n\
Connect your first calendar with:\n \
caldir connect <provider>\n\n\
Example:\n \
caldir connect google"
);
}
Ok(())
}
fn resolve_calendars(calendar_filter: Option<&str>) -> Result<Vec<Calendar>> {
let caldir = Caldir::load()?;
let all_calendars = caldir.calendars();
match calendar_filter {
Some(slug) => match all_calendars.into_iter().find(|c| c.slug == slug) {
Some(cal) => Ok(vec![cal]),
None => {
let available: Vec<_> = caldir.calendars().iter().map(|c| c.slug.clone()).collect();
anyhow::bail!(
"Calendar '{}' not found. Available: {}",
slug,
available.join(", ")
);
}
},
None => Ok(all_calendars),
}
}