use std::error::Error;
use aimcal_core::{
Aim, DateTimeAnchor, Event, EventConditions, EventDraft, EventPatch, EventStatus, Id, Kind,
LooseDateTime, Pager,
};
use clap::{ArgMatches, Command};
use colored::Colorize;
use crate::arg::CalendarArgs;
use crate::arg::{CommonArgs, EventArgs, EventOrTodoArgs};
use crate::event_formatter::{EventColumn, EventFormatter};
use crate::prompt::{DuplicateChoice, is_terminal, prompt_duplicate_choice, prompt_time};
use crate::tui;
use crate::util::{OutputFormat, parse_datetime, parse_datetime_range};
#[derive(Debug, Clone)]
pub struct CmdEventNew {
pub calendar_id: Option<String>,
pub description: Option<String>,
pub end: Option<String>,
pub start: Option<String>,
pub status: Option<EventStatus>,
pub summary: Option<String>,
pub output_format: OutputFormat,
}
impl CmdEventNew {
pub const NAME: &str = "new";
pub fn command() -> Command {
let (args, event_args) = args();
Command::new(Self::NAME)
.alias("add")
.about("Add a new event")
.arg(args.summary(true))
.arg(CalendarArgs::new(true).calendar())
.arg(event_args.start())
.arg(event_args.end())
.arg(args.description())
.arg(event_args.status())
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
calendar_id: CalendarArgs::get_calendar(matches),
description: EventOrTodoArgs::get_description(matches),
start: EventArgs::get_start(matches),
end: EventArgs::get_end(matches),
status: EventArgs::get_status(matches),
summary: EventOrTodoArgs::get_summary(matches),
output_format: CommonArgs::get_output_format(matches),
}
}
pub async fn run(self, aim: &mut Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, "adding new event...");
let tui = self.tui();
let now = aim.now();
let mut draft = aim.default_event_draft();
match (self.start, self.end) {
(Some(start), Some(end)) => {
(draft.start, draft.end) = parse_datetime_range(&now, &start, &end)?;
}
(Some(start), None) => {
draft.start = parse_datetime(&now, &start)?;
draft.end = None;
}
(None, Some(end)) => {
draft.start = None;
draft.end = parse_datetime(&now, &end)?;
}
(None, None) => {}
}
if let Some(description) = self.description {
draft.description = Some(description);
}
draft.calendar_id = self.calendar_id;
if let Some(status) = self.status {
draft.status = status;
}
if let Some(summary) = self.summary {
draft.summary = summary;
}
if tui {
let Some(draft_tui) = tui::draft_event(aim, draft)? else {
tracing::info!("user cancel the event creation");
return Ok(());
};
draft = draft_tui;
}
Self::new_event(aim, draft, self.output_format).await
}
pub async fn new_event(
aim: &mut Aim,
draft: EventDraft,
output_format: OutputFormat,
) -> Result<(), Box<dyn Error>> {
if !draft.summary.is_empty() && is_terminal() {
let uid = match aim.find_latest_event_by_summary(&draft.summary).await? {
Some(existing) => {
let existing_id = existing
.short_id()
.map_or_else(|| existing.uid().into_owned(), |id| id.get().to_string());
let choice = prompt_duplicate_choice("Event", &existing_id, &draft.summary)?;
match choice {
DuplicateChoice::UpdateExisting => Some(existing.uid().into_owned()),
DuplicateChoice::CreateNew => None,
}
}
None => None,
};
if let Some(uid) = uid {
let event = aim.update_event(&Id::Uid(uid), draft.into()).await?;
print_events(aim, &[event], output_format);
return Ok(());
}
}
let event = aim.new_event(draft).await?;
print_events(aim, &[event], output_format);
Ok(())
}
pub(crate) fn tui(&self) -> bool {
Self::need_tui(&self.summary, &self.start)
}
#[expect(clippy::ref_option)]
pub(crate) fn need_tui(summary: &Option<String>, start: &Option<String>) -> bool {
summary.is_none() || start.is_none()
}
}
#[derive(Debug, Clone)]
pub struct CmdEventEdit {
pub id: Id,
pub description: Option<String>,
pub end: Option<String>,
pub start: Option<String>,
pub status: Option<EventStatus>,
pub summary: Option<String>,
pub output_format: OutputFormat,
}
impl CmdEventEdit {
pub const NAME: &str = "edit";
pub fn command() -> Command {
let (args, event_args) = args();
Command::new(Self::NAME)
.about("Edit a event item")
.arg(args.id())
.arg(args.summary(false))
.arg(event_args.start())
.arg(event_args.end())
.arg(args.description())
.arg(event_args.status())
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
id: EventOrTodoArgs::get_id(matches),
description: EventOrTodoArgs::get_description(matches),
start: EventArgs::get_start(matches),
end: EventArgs::get_end(matches),
status: EventArgs::get_status(matches),
summary: EventOrTodoArgs::get_summary(matches),
output_format: CommonArgs::get_output_format(matches),
}
}
pub fn new_tui(id: Id, output_format: OutputFormat) -> Self {
Self {
id,
description: None,
end: None,
start: None,
status: None,
summary: None,
output_format,
}
}
pub async fn run(self, aim: &mut Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, "editing event...");
let tui = self.tui();
let (start, end) = match (self.start, self.end) {
(Some(start), Some(end)) => {
let (a, b) = parse_datetime_range(&aim.now(), &start, &end)?;
(Some(a), Some(b))
}
(Some(start), None) => (Some(parse_datetime(&aim.now(), &start)?), None),
(None, Some(end)) => (None, Some(parse_datetime(&aim.now(), &end)?)),
(None, None) => (None, None),
};
let mut patch = EventPatch {
description: self.description.map(|d| (!d.is_empty()).then_some(d)),
end,
start,
status: self.status,
summary: self.summary,
};
if tui {
let event = aim.get_event(&self.id).await?;
patch = if let Some(data) = tui::patch_event(aim, &event, patch)? {
data
} else {
tracing::info!("user cancel the event editing");
return Ok(());
}
}
let event = aim.update_event(&self.id, patch).await?;
print_events(aim, &[event], self.output_format);
Ok(())
}
pub(crate) fn tui(&self) -> bool {
self.description.is_none()
&& self.end.is_none()
&& self.start.is_none()
&& self.status.is_none()
&& self.summary.is_none()
}
}
#[derive(Debug, Clone)]
pub struct CmdEventDelay {
pub ids: Vec<Id>,
pub time: Option<DateTimeAnchor>,
pub output_format: OutputFormat,
}
impl CmdEventDelay {
pub const NAME: &str = "delay";
pub fn command() -> Command {
let (args, _event_args) = args();
Command::new(Self::NAME)
.about("Delay an event's time by a specified time based on original start")
.arg(args.ids())
.arg(args.time("delay"))
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
ids: EventOrTodoArgs::get_ids(matches),
time: EventOrTodoArgs::get_time(matches),
output_format: CommonArgs::get_output_format(matches),
}
}
pub async fn run(self, aim: &mut Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, "delaying event...");
let time = match self.time {
Some(t) => t,
None => prompt_time()?,
};
let mut events = Vec::with_capacity(self.ids.len());
for id in &self.ids {
let event = aim.get_event(id).await?;
let (start, end) = match (event.start(), event.end()) {
(Some(start), end) => {
let s = time.clone().resolve_at(&start);
let e = end.map(|a| time.clone().resolve_at(&a));
(Some(s), e)
}
(None, Some(end)) => {
let e = time.clone().resolve_at(&end);
(None, Some(e))
}
(None, None) => {
let s = time
.clone()
.resolve_since_zoned(&aim.now())
.map_err(|e| format!("Failed to resolve since zoned: {e}"))?;
(Some(s), None)
}
};
let patch = EventPatch {
start: Some(start),
end: Some(end),
..Default::default()
};
let event = aim.update_event(id, patch).await?;
events.push(event);
}
print_events(aim, &events, self.output_format);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CmdEventReschedule {
pub ids: Vec<Id>,
pub time: Option<DateTimeAnchor>,
pub output_format: OutputFormat,
}
impl CmdEventReschedule {
pub const NAME: &str = "reschedule";
pub fn command() -> Command {
let (args, _event_args) = args();
Command::new(Self::NAME)
.about("Reschedule event's due to a specified time based on now")
.arg(args.ids())
.arg(args.time("reschedule"))
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
ids: EventOrTodoArgs::get_ids(matches),
time: EventOrTodoArgs::get_time(matches),
output_format: CommonArgs::get_output_format(matches),
}
}
pub async fn run(self, aim: &mut Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, "rescheduling event...");
let time = match self.time {
Some(t) => t,
None => prompt_time()?,
};
let mut events = Vec::with_capacity(self.ids.len());
for id in &self.ids {
let event = aim.get_event(id).await?;
let (start, end) = match (event.start(), event.end()) {
(Some(start), Some(end)) => {
use LooseDateTime::{DateOnly, Floating, Local};
let s = time
.clone()
.resolve_since_zoned(&aim.now())
.map_err(|e| format!("Failed to resolve since zoned: {e}"))?;
#[rustfmt::skip]
let e = match (start, end) {
(DateOnly(ds), DateOnly(de)) => (s.date() + (de - ds)).into(),
(DateOnly(ds), Floating(dte)) => (s.date() + (dte.date() - ds)).into(),
(DateOnly(ds), Local(dte)) => (s.date() + (dte.date() - ds)).into(),
(Floating(dts), DateOnly(dte)) => s.clone() + (dte - dts.date()),
(Floating(dts), Floating(dte)) => s.clone() + (dte - dts),
(Floating(dts), Local(dte)) => s.clone() + (dte.datetime() - dts), (Local(dts), DateOnly(de)) => s.clone() + (de - dts.date()),
(Local(dts), Floating(dte)) => s.clone() + (dte - dts.datetime()), (Local(dts), Local(dte)) => s.clone() + (dte - dts),
};
(Some(s), Some(e))
}
(_, None) => {
let s = time
.clone()
.resolve_since_zoned(&aim.now())
.map_err(|e| format!("Failed to resolve since zoned: {e}"))?;
(Some(s), None)
}
(None, Some(_)) => {
let e = time
.clone()
.resolve_since_zoned(&aim.now())
.map_err(|e| format!("Failed to resolve since zoned: {e}"))?;
(None, Some(e))
}
};
let patch = EventPatch {
start: Some(start),
end: Some(end),
..Default::default()
};
let event = aim.update_event(id, patch).await?;
events.push(event);
}
print_events(aim, &events, self.output_format);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CmdEventList {
pub conds: EventConditions,
pub output_format: OutputFormat,
}
impl CmdEventList {
pub const NAME: &str = "list";
pub fn command() -> Command {
Command::new(Self::NAME)
.about("List events")
.arg(CalendarArgs::new(true).calendar())
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
conds: EventConditions {
startable: Some(DateTimeAnchor::today()),
calendar_id: CalendarArgs::get_calendar(matches),
..Default::default()
},
output_format: CommonArgs::get_output_format(matches),
}
}
pub async fn run(self, aim: &Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, "listing events...");
Self::list(aim, &self.conds, self.output_format).await
}
#[expect(clippy::cast_possible_truncation)]
pub async fn list(
aim: &Aim,
conds: &EventConditions,
output_format: OutputFormat,
) -> Result<(), Box<dyn Error>> {
const LIMIT: i64 = 128;
let pager: Pager = (LIMIT, 0).into();
let events = aim.list_events(conds, &pager).await?;
if events.len() >= (LIMIT as usize) {
let total = aim.count_events(conds).await?;
if total > LIMIT {
let prompt = format!("Displaying the {LIMIT}/{total} events");
println!("{}", prompt.italic());
}
} else if events.is_empty() && output_format == OutputFormat::Table {
println!("{}", "No events found".italic());
return Ok(());
}
print_events(aim, &events, output_format);
Ok(())
}
}
const fn args() -> (EventOrTodoArgs, EventArgs) {
(
EventOrTodoArgs::new(Some(Kind::Event)),
EventArgs::new(true),
)
}
fn print_events(aim: &Aim, events: &[impl Event], output_format: OutputFormat) {
use EventColumn::{DateTimeSpan, Id, ShortId, Summary, Uid};
let columns = match output_format {
OutputFormat::Table => vec![Id, DateTimeSpan, Summary],
OutputFormat::Json => vec![Uid, ShortId, DateTimeSpan, Summary],
};
let formatter = EventFormatter::new(aim.now(), columns, output_format);
println!("{}", formatter.format(events));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_event_new_command() {
let args = [
"new",
"Another summary",
"--calendar",
"work",
"--description",
"A description",
"--start",
"2025-01-01 12:00:00",
"--end",
"2025-01-01 14:00:00",
"--status",
"tentative",
"--output-format",
"json",
];
let matches = CmdEventNew::command().try_get_matches_from(args).unwrap();
let parsed = CmdEventNew::from(&matches);
assert_eq!(parsed.description, Some("A description".to_string()));
assert_eq!(parsed.calendar_id, Some("work".to_string()));
assert_eq!(parsed.end, Some("2025-01-01 14:00:00".to_string()));
assert_eq!(parsed.start, Some("2025-01-01 12:00:00".to_string()));
assert_eq!(parsed.status, Some(EventStatus::Tentative));
assert_eq!(parsed.summary, Some("Another summary".to_string()));
assert!(!parsed.tui());
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_event_new_command_with_tui_mode() {
let args = ["new"];
let matches = CmdEventNew::command().try_get_matches_from(args).unwrap();
let parsed = CmdEventNew::from(&matches);
assert!(parsed.tui());
}
#[test]
fn parses_event_edit_command() {
let args = [
"edit",
"test_id",
"--description",
"A description",
"--start",
"2025-01-01 12:00:00",
"--end",
"2025-01-01 14:00:00",
"--status",
"tentative",
"--summary",
"Another summary",
"--output-format",
"json",
];
let matches = CmdEventEdit::command().try_get_matches_from(args).unwrap();
let parsed = CmdEventEdit::from(&matches);
assert_eq!(parsed.id, Id::ShortIdOrUid("test_id".to_string()));
assert_eq!(parsed.description, Some("A description".to_string()));
assert_eq!(parsed.end, Some("2025-01-01 14:00:00".to_string()));
assert_eq!(parsed.start, Some("2025-01-01 12:00:00".to_string()));
assert_eq!(parsed.status, Some(EventStatus::Tentative));
assert_eq!(parsed.summary, Some("Another summary".to_string()));
assert!(!parsed.tui());
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_event_edit_command_with_tui_mode() {
let cmd = CmdEventEdit::command();
let matches = cmd.try_get_matches_from(["edit", "test_id"]).unwrap();
let parsed = CmdEventEdit::from(&matches);
assert!(parsed.tui());
assert_eq!(parsed.id, Id::ShortIdOrUid("test_id".to_string()));
}
#[test]
fn parses_event_delay_command() {
let args = [
"delay",
"a",
"b",
"c",
"--time",
"1d",
"--output-format",
"json",
];
let matches = CmdEventDelay::command().try_get_matches_from(args).unwrap();
let parsed = CmdEventDelay::from(&matches);
let expected_ids = vec![
Id::ShortIdOrUid("a".to_string()),
Id::ShortIdOrUid("b".to_string()),
Id::ShortIdOrUid("c".to_string()),
];
assert_eq!(parsed.ids, expected_ids);
assert_eq!(parsed.time, Some(DateTimeAnchor::InDays(1)));
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_event_reschedule_command() {
let args = [
"reschedule",
"a",
"b",
"c",
"--time",
"1d",
"--output-format",
"json",
];
let matches = CmdEventReschedule::command()
.try_get_matches_from(args)
.unwrap();
let parsed = CmdEventReschedule::from(&matches);
let expected_ids = vec![
Id::ShortIdOrUid("a".to_string()),
Id::ShortIdOrUid("b".to_string()),
Id::ShortIdOrUid("c".to_string()),
];
assert_eq!(parsed.ids, expected_ids);
assert_eq!(parsed.time, Some(DateTimeAnchor::InDays(1)));
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_event_list_command() {
let args = ["list", "--calendar", "work", "--output-format", "json"];
let matches = CmdEventList::command().try_get_matches_from(args).unwrap();
let parsed = CmdEventList::from(&matches);
assert_eq!(parsed.conds.calendar_id, Some("work".to_string()));
assert_eq!(parsed.output_format, OutputFormat::Json);
}
}