use std::error::Error;
use aimcal_core::{
Aim, DateTimeAnchor, Id, Kind, Priority, SortOrder, Todo, TodoConditions, TodoDraft, TodoPatch,
TodoSort, TodoStatus,
};
use clap::{ArgMatches, Command};
use colored::Colorize;
use crate::arg::{CalendarArgs, CommonArgs, EventOrTodoArgs, TodoArgs};
use crate::prompt::{
DuplicateChoice, is_terminal, prompt_duplicate_choice, prompt_time, prompt_time_opt,
};
use crate::todo_formatter::{TodoColumn, TodoFormatter};
use crate::tui;
use crate::util::{OutputFormat, parse_datetime};
#[derive(Debug, Clone)]
pub struct CmdTodoNew {
pub calendar_id: Option<String>,
pub description: Option<String>,
pub due: Option<String>,
pub percent_complete: Option<u8>,
pub priority: Option<Priority>,
pub status: Option<TodoStatus>,
pub summary: Option<String>,
pub output_format: OutputFormat,
}
impl CmdTodoNew {
pub const NAME: &str = "new";
pub fn command() -> Command {
let (args, todo_args) = args();
Command::new(Self::NAME)
.alias("add")
.about("Add a new todo")
.arg(args.summary(true))
.arg(CalendarArgs::new(true).calendar())
.arg(todo_args.due())
.arg(args.description())
.arg(todo_args.percent_complete())
.arg(todo_args.priority())
.arg(todo_args.status())
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
calendar_id: CalendarArgs::get_calendar(matches),
description: EventOrTodoArgs::get_description(matches),
due: TodoArgs::get_due(matches),
percent_complete: TodoArgs::get_percent_complete(matches),
priority: TodoArgs::get_priority(matches),
status: TodoArgs::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 todo...");
let tui = self.tui();
let now = aim.now();
let mut draft = aim
.default_todo_draft()
.map_err(|e| format!("Failed to create default todo draft: {e}"))?;
if let Some(desc) = self.description {
draft.description = Some(desc);
}
draft.calendar_id = self.calendar_id;
if let Some(due) = &self.due {
draft.due = parse_datetime(&now, due)?;
}
if let Some(percent) = self.percent_complete {
draft.percent_complete = Some(percent);
}
if let Some(priority) = self.priority {
draft.priority = Some(priority);
}
if let Some(status) = self.status {
draft.status = status;
}
if let Some(summary) = &self.summary {
draft.summary.clone_from(summary);
}
if tui {
let Some(draft_tui) = tui::draft_todo(aim, draft)? else {
tracing::info!("user cancel the todo editing");
return Ok(());
};
draft = draft_tui;
}
Self::new_todo(aim, draft, self.output_format).await
}
pub async fn new_todo(
aim: &mut Aim,
draft: TodoDraft,
output_format: OutputFormat,
) -> Result<(), Box<dyn Error>> {
if !draft.summary.is_empty() && is_terminal() {
let uid = match aim.find_latest_todo_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("Todo", &existing_id, &draft.summary)?;
match choice {
DuplicateChoice::UpdateExisting => Some(existing.uid().into_owned()),
DuplicateChoice::CreateNew => None,
}
}
None => None,
};
if let Some(uid) = uid {
let todo = aim.update_todo(&Id::Uid(uid), draft.into()).await?;
print_todos(aim, &[todo], output_format);
return Ok(());
}
}
let todo = aim.new_todo(draft).await?;
print_todos(aim, &[todo], output_format);
Ok(())
}
pub(crate) fn tui(&self) -> bool {
Self::need_tui(&self.summary)
}
#[expect(clippy::ref_option)]
pub(crate) fn need_tui(summary: &Option<String>) -> bool {
summary.is_none()
}
}
#[derive(Debug, Clone)]
pub struct CmdTodoEdit {
pub id: Id,
pub description: Option<String>,
pub due: Option<String>,
pub percent_complete: Option<u8>,
pub priority: Option<Priority>,
pub status: Option<TodoStatus>,
pub summary: Option<String>,
pub output_format: OutputFormat,
}
impl CmdTodoEdit {
pub const NAME: &str = "edit";
pub fn command() -> Command {
let (args, todo_args) = args();
Command::new(Self::NAME)
.about("Edit a todo")
.arg(args.id())
.arg(args.summary(false))
.arg(todo_args.due())
.arg(args.description())
.arg(todo_args.percent_complete())
.arg(todo_args.priority())
.arg(todo_args.status())
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
id: EventOrTodoArgs::get_id(matches),
description: EventOrTodoArgs::get_description(matches),
due: TodoArgs::get_due(matches),
percent_complete: TodoArgs::get_percent_complete(matches),
priority: TodoArgs::get_priority(matches),
status: TodoArgs::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,
due: None,
percent_complete: None,
priority: None,
status: None,
summary: None,
output_format,
}
}
pub async fn run(self, aim: &mut Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, "editing todo...");
let tui = self.tui();
let mut patch = TodoPatch {
description: self.description.map(|d| (!d.is_empty()).then_some(d)),
due: self
.due
.as_ref()
.map(|a| parse_datetime(&aim.now(), a))
.transpose()?,
priority: self.priority,
percent_complete: None,
status: self.status,
summary: self.summary,
};
if tui {
let todo = aim.get_todo(&self.id).await?;
patch = if let Some(data) = tui::patch_todo(aim, &todo, patch)? {
data
} else {
tracing::info!("user cancel the todo editing");
return Ok(());
};
}
let todo = aim.update_todo(&self.id, patch).await?;
print_todos(aim, &[todo], self.output_format);
Ok(())
}
pub(crate) fn tui(&self) -> bool {
self.description.is_none()
&& self.due.is_none()
&& self.percent_complete.is_none()
&& self.priority.is_none()
&& self.status.is_none()
&& self.summary.is_none()
}
}
macro_rules! cmd_status {
($cmd: ident, $status:ident, $name: expr, $desc: expr) => {
#[derive(Debug, Clone)]
pub struct $cmd {
pub ids: Vec<Id>,
pub output_format: OutputFormat,
}
impl $cmd {
pub const NAME: &str = $name;
pub fn command() -> Command {
let (args, _todo_args) = args();
Command::new(Self::NAME)
.about(concat!("Mark a todo as ", $desc))
.arg(args.ids())
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
ids: EventOrTodoArgs::get_ids(matches),
output_format: CommonArgs::get_output_format(matches),
}
}
pub async fn run(self, aim: &Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, concat!("marking todos as ", $desc));
let mut todos = vec![];
for id in self.ids {
let patch = TodoPatch {
status: Some(TodoStatus::$status),
..Default::default()
};
let todo = aim.update_todo(&id, patch).await?;
todos.push(todo);
}
print_todos(aim, &todos, self.output_format);
Ok(())
}
}
};
}
cmd_status!(CmdTodoUndo, NeedsAction, "undo", "needs-action");
cmd_status!(CmdTodoDone, Completed, "done", "completed");
cmd_status!(CmdTodoCancel, Cancelled, "cancel", "canceled");
#[derive(Debug, Clone)]
pub struct CmdTodoDelay {
pub ids: Vec<Id>,
pub time: Option<DateTimeAnchor>,
pub output_format: OutputFormat,
}
impl CmdTodoDelay {
pub const NAME: &str = "delay";
pub fn command() -> Command {
let (args, _todo_args) = args();
Command::new(Self::NAME)
.about("Delay todo's due by a specified time based on original due")
.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 todo...");
let time = match self.time {
Some(t) => t,
None => prompt_time()?,
};
let mut todos = vec![];
for id in &self.ids {
let todo = aim.get_todo(id).await?;
let new_due = Some(match todo.due() {
Some(due) => time.clone().resolve_at(&due),
None => time
.clone()
.resolve_since_zoned(&aim.now())
.map_err(|e| format!("Failed to resolve since zoned: {e}"))?,
});
let patch = TodoPatch {
due: Some(new_due),
..Default::default()
};
let todo = aim.update_todo(id, patch).await?;
todos.push(todo);
}
print_todos(aim, &todos, self.output_format);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CmdTodoReschedule {
pub ids: Vec<Id>,
pub time: Option<DateTimeAnchor>,
pub output_format: OutputFormat,
}
impl CmdTodoReschedule {
pub const NAME: &str = "reschedule";
pub fn command() -> Command {
let (args, _todo_args) = args();
Command::new(Self::NAME)
.about("Reschedule todo'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 todo...");
let time = match self.time {
Some(t) => Some(t),
None => prompt_time_opt()?,
};
let mut todos = vec![];
for id in &self.ids {
let new_due = match time.as_ref() {
Some(a) => Some(
a.clone()
.resolve_since_zoned(&aim.now())
.map_err(|e| format!("Failed to resolve since zoned: {e}"))?,
),
None => None,
};
let patch = TodoPatch {
due: Some(new_due),
..Default::default()
};
let todo = aim.update_todo(id, patch).await?;
todos.push(todo);
}
print_todos(aim, &todos, self.output_format);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CmdTodoList {
pub conds: TodoConditions,
pub output_format: OutputFormat,
}
impl CmdTodoList {
pub const NAME: &str = "list";
pub fn command() -> Command {
Command::new(Self::NAME)
.about("List todos")
.arg(CalendarArgs::new(true).calendar())
.arg(CommonArgs::output_format())
}
pub fn from(matches: &ArgMatches) -> Self {
Self {
conds: TodoConditions {
status: Some(TodoStatus::NeedsAction),
due: None,
calendar_id: CalendarArgs::get_calendar(matches),
},
output_format: CommonArgs::get_output_format(matches),
}
}
pub async fn run(self, aim: &Aim) -> Result<(), Box<dyn Error>> {
tracing::debug!(?self, "listing todos...");
Self::list(aim, &self.conds, self.output_format).await?;
Ok(())
}
#[expect(clippy::cast_possible_truncation)]
pub async fn list(
aim: &Aim,
conds: &TodoConditions,
output_format: OutputFormat,
) -> Result<(), Box<dyn Error>> {
const LIMIT: i64 = 128;
let pager = (LIMIT, 0).into();
let sort = vec![
TodoSort::Priority {
order: SortOrder::Asc,
none_first: None,
},
TodoSort::Due(SortOrder::Asc),
];
let mut todos = aim.list_todos(conds, &sort, &pager).await?;
todos.reverse();
if todos.len() >= LIMIT as usize {
let total = aim.count_todos(conds).await?;
if total > LIMIT {
let prompt = format!("Displaying the {LIMIT}/{total} todos");
println!("{}", prompt.italic());
}
} else if todos.is_empty() && output_format == OutputFormat::Table {
println!("{}", "No todos found".italic());
}
print_todos(aim, &todos, output_format);
Ok(())
}
}
const fn args() -> (EventOrTodoArgs, TodoArgs) {
(EventOrTodoArgs::new(Some(Kind::Todo)), TodoArgs::new(true))
}
fn print_todos(aim: &Aim, todos: &[impl Todo], output_format: OutputFormat) {
use TodoColumn::{Due, Id, Priority, ShortId, Status, Summary, Uid};
let columns = match output_format {
OutputFormat::Table => vec![Status, Id, Priority, Due, Summary],
OutputFormat::Json => vec![Uid, ShortId, Status, Priority, Due, Summary],
};
let formatter = TodoFormatter::new(aim.now(), columns, output_format);
println!("{}", formatter.format(todos));
}
#[cfg(test)]
mod tests {
use aimcal_core::Priority;
use super::*;
#[test]
fn parses_todo_new_command() {
let args = [
"new",
"Another summary",
"--calendar",
"work",
"--description",
"A description",
"--due",
"2025-01-01 12:00:00",
"--percent",
"66",
"--priority",
"1",
"--status",
"completed",
"--output-format",
"json",
];
let matches = CmdTodoNew::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoNew::from(&matches);
assert_eq!(parsed.description, Some("A description".to_string()));
assert_eq!(parsed.calendar_id, Some("work".to_string()));
assert_eq!(parsed.due, Some("2025-01-01 12:00:00".to_string()));
assert_eq!(parsed.percent_complete, Some(66));
assert_eq!(parsed.priority, Some(Priority::P1));
assert_eq!(parsed.status, Some(TodoStatus::Completed));
assert_eq!(parsed.summary, Some("Another summary".to_string()));
assert!(!parsed.tui());
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_new_command_with_tui_mode() {
let args = ["new", "--output-format", "json"];
let matches = CmdTodoNew::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoNew::from(&matches);
assert!(parsed.tui());
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_edit_command() {
let args = [
"edit",
"test_id",
"--description",
"A description",
"--due",
"2025-01-01 12:00:00",
"--priority",
"1",
"--percent",
"66",
"--status",
"needs-action",
"--summary",
"Another summary",
"--output-format",
"json",
];
let matches = CmdTodoEdit::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoEdit::from(&matches);
assert_eq!(parsed.id, Id::ShortIdOrUid("test_id".to_string()));
assert_eq!(parsed.description, Some("A description".to_string()));
assert_eq!(parsed.due, Some("2025-01-01 12:00:00".to_string()));
assert_eq!(parsed.priority, Some(Priority::P1));
assert_eq!(parsed.percent_complete, Some(66));
assert_eq!(parsed.status, Some(TodoStatus::NeedsAction));
assert_eq!(parsed.summary, Some("Another summary".to_string()));
assert!(!parsed.tui());
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_edit_command_with_tui_mode() {
let cmd = CmdTodoEdit::command();
let matches = cmd
.try_get_matches_from(["edit", "test_id", "--output-format", "json"])
.unwrap();
let parsed = CmdTodoEdit::from(&matches);
assert!(parsed.tui());
assert_eq!(parsed.id, Id::ShortIdOrUid("test_id".to_string()));
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_done_command() {
let args = ["done", "abc", "--output-format", "json"];
let matches = CmdTodoDone::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoDone::from(&matches);
assert_eq!(parsed.ids, vec![Id::ShortIdOrUid("abc".to_string())]);
assert_eq!(parsed.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_done_command_with_multiple_ids() {
let args = ["done", "a", "b", "c", "--output-format", "json"];
let matches = CmdTodoDone::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoDone::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.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_undo_command() {
let args = ["undo", "a", "b", "c", "--output-format", "json"];
let matches = CmdTodoUndo::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoUndo::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.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_cancel_command() {
let args = ["cancel", "a", "b", "c", "--output-format", "json"];
let matches = CmdTodoCancel::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoCancel::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.output_format, OutputFormat::Json);
}
#[test]
fn parses_todo_delay_command() {
let args = [
"delay",
"a",
"b",
"c",
"--time",
"1d",
"--output-format",
"json",
];
let matches = CmdTodoDelay::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoDelay::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_todo_reschedule_command() {
let args = [
"reschedule",
"a",
"b",
"c",
"--time",
"1d",
"--output-format",
"json",
];
let matches = CmdTodoReschedule::command()
.try_get_matches_from(args)
.unwrap();
let parsed = CmdTodoReschedule::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_todo_list_command() {
let args = ["list", "--calendar", "work", "--output-format", "json"];
let matches = CmdTodoList::command().try_get_matches_from(args).unwrap();
let parsed = CmdTodoList::from(&matches);
assert_eq!(parsed.conds.calendar_id, Some("work".to_string()));
assert_eq!(parsed.output_format, OutputFormat::Json);
}
}