1use std::{error::Error, ffi::OsString, path::PathBuf};
6
7use aimcal_core::{APP_NAME, Aim};
8use clap::{ArgMatches, Command, ValueHint, arg, builder::styling, crate_version, value_parser};
9use colored::Colorize;
10use futures::{FutureExt, future::BoxFuture};
11use tracing::level_filters::LevelFilter;
12use tracing_subscriber::{EnvFilter, Registry, prelude::*};
13
14use crate::cmd_calendar::{CmdCalendarList, CmdCalendarShow};
15use crate::cmd_event::{
16 CmdEventDelay, CmdEventEdit, CmdEventList, CmdEventNew, CmdEventReschedule,
17};
18use crate::cmd_generate_completion::CmdGenerateCompletion;
19use crate::cmd_todo::{
20 CmdTodoCancel, CmdTodoDelay, CmdTodoDone, CmdTodoEdit, CmdTodoList, CmdTodoNew,
21 CmdTodoReschedule, CmdTodoUndo,
22};
23use crate::cmd_toplevel::{CmdDashboard, CmdDelay, CmdFlush, CmdReschedule};
24use crate::cmd_tui::{CmdEdit, CmdNew};
25use crate::config::parse_config;
26
27pub async fn run() -> Result<(), Box<dyn Error>> {
32 init_tracing()?;
33
34 ctrlc::set_handler(move || {})?;
38
39 let err = match Cli::parse() {
40 Ok(cli) => match cli.run().await {
41 Ok(()) => return Ok(()),
42 Err(e) => e,
43 },
44 Err(e) => e,
45 };
46 println!("{} {}", "Error:".red(), err);
47 Ok(())
48}
49
50pub fn init_tracing() -> Result<(), Box<dyn Error>> {
51 let stdout_log = tracing_subscriber::fmt::layer().pretty();
52
53 let filter = EnvFilter::builder()
54 .with_default_directive(LevelFilter::ERROR.into())
55 .from_env_lossy();
56
57 let subscriber = Registry::default().with(filter).with(stdout_log);
58
59 tracing::subscriber::set_global_default(subscriber)?;
60 Ok(())
61}
62
63#[derive(Debug)]
65pub struct Cli {
66 pub config: Option<PathBuf>,
68
69 pub command: Commands,
71}
72
73impl Cli {
74 #[must_use]
76 pub fn command() -> Command {
77 const STYLES: styling::Styles = styling::Styles::styled()
78 .header(styling::AnsiColor::Green.on_default().bold())
79 .usage(styling::AnsiColor::Green.on_default().bold())
80 .literal(styling::AnsiColor::Blue.on_default().bold())
81 .placeholder(styling::AnsiColor::Cyan.on_default());
82
83 Command::new(APP_NAME)
84 .about("Analyze. Interact. Manage Your Time, with calendar support.")
85 .author("Zexin Yuan <aim@yzx9.xyz>")
86 .version(crate_version!())
87 .styles(STYLES)
88 .subcommand_required(false) .arg_required_else_help(false)
90 .arg(
91 arg!(-c --config [CONFIG] "Path to the configuration file")
92 .long_help(
93 "\
94Path to the configuration file. Can be specified via AIM_CONFIG environment variable. \
95Defaults to $XDG_CONFIG_HOME/aim/config.toml on Linux and MacOS, \
96%LOCALAPPDATA%/aim/config.toml on Windows.",
97 )
98 .value_parser(value_parser!(PathBuf))
99 .value_hint(ValueHint::FilePath),
100 )
101 .subcommand(CmdDashboard::command())
102 .subcommand(CmdNew::command())
103 .subcommand(CmdEdit::command())
104 .subcommand(CmdDelay::command())
105 .subcommand(CmdReschedule::command())
106 .subcommand(
107 Command::new("calendar")
108 .about("Manage calendars")
109 .arg_required_else_help(true)
110 .subcommand_required(true)
111 .subcommand(CmdCalendarList::command())
112 .subcommand(CmdCalendarShow::command()),
113 )
114 .subcommand(
115 Command::new("event")
116 .alias("e")
117 .about("Manage your event list")
118 .arg_required_else_help(true)
119 .subcommand_required(true)
120 .subcommand(CmdEventNew::command())
121 .subcommand(CmdEventEdit::command())
122 .subcommand(CmdEventDelay::command())
123 .subcommand(CmdEventReschedule::command())
124 .subcommand(CmdEventList::command()),
125 )
126 .subcommand(
127 Command::new("todo")
128 .alias("t")
129 .about("Manage your todo list")
130 .arg_required_else_help(true)
131 .subcommand_required(true)
132 .subcommand(CmdTodoNew::command())
133 .subcommand(CmdTodoEdit::command())
134 .subcommand(CmdTodoDone::command())
135 .subcommand(CmdTodoUndo::command())
136 .subcommand(CmdTodoCancel::command())
137 .subcommand(CmdTodoDelay::command())
138 .subcommand(CmdTodoReschedule::command())
139 .subcommand(CmdTodoList::command()),
140 )
141 .subcommand(CmdTodoDone::command())
142 .subcommand(CmdFlush::command())
143 .subcommand(CmdGenerateCompletion::command())
144 }
145
146 pub fn parse() -> Result<Self, Box<dyn Error>> {
151 let commands = Self::command();
152 let matches = commands.get_matches();
153 Self::from(&matches)
154 }
155
156 pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
161 where
162 I: IntoIterator<Item = T>,
163 T: Into<OsString> + Clone,
164 {
165 let commands = Self::command();
166 let matches = commands.try_get_matches_from(args)?;
167 Self::from(&matches)
168 }
169
170 pub fn from(matches: &ArgMatches) -> Result<Self, Box<dyn Error>> {
175 use Commands::{
176 CalendarList, CalendarShow, Dashboard, Delay, Edit, EventDelay, EventEdit, EventList,
177 EventNew, EventReschedule, Flush, GenerateCompletion, New, Reschedule, TodoCancel,
178 TodoDelay, TodoDone, TodoEdit, TodoList, TodoNew, TodoReschedule, TodoUndo,
179 };
180 let command = match matches.subcommand() {
181 Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
182 Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
183 Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
184 Some((CmdDelay::NAME, matches)) => Delay(CmdDelay::from(matches)),
185 Some((CmdReschedule::NAME, matches)) => Reschedule(CmdReschedule::from(matches)),
186 Some((CmdFlush::NAME, matches)) => Flush(CmdFlush::from(matches)),
187 Some(("calendar", matches)) => match matches.subcommand() {
188 Some((CmdCalendarList::NAME, matches)) => {
189 CalendarList(CmdCalendarList::from(matches))
190 }
191 Some((CmdCalendarShow::NAME, matches)) => {
192 CalendarShow(CmdCalendarShow::from(matches))
193 }
194 _ => unreachable!(),
195 },
196 Some(("event", matches)) => match matches.subcommand() {
197 Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)),
198 Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
199 Some((CmdEventDelay::NAME, matches)) => EventDelay(CmdEventDelay::from(matches)),
200 Some((CmdEventReschedule::NAME, matches)) => {
201 EventReschedule(CmdEventReschedule::from(matches))
202 }
203 Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
204 _ => unreachable!(),
205 },
206 Some(("todo", matches)) => match matches.subcommand() {
207 Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)),
208 Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
209 Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
210 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
211 Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
212 Some((CmdTodoDelay::NAME, matches)) => TodoDelay(CmdTodoDelay::from(matches)),
213 Some((CmdTodoReschedule::NAME, matches)) => {
214 TodoReschedule(CmdTodoReschedule::from(matches))
215 }
216 Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
217 _ => unreachable!(),
218 },
219 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
220 Some((CmdGenerateCompletion::NAME, matches)) => {
221 GenerateCompletion(CmdGenerateCompletion::from(matches))
222 }
223 None => Dashboard(CmdDashboard),
224 _ => unreachable!(),
225 };
226
227 let config = matches.get_one("config").cloned();
228 Ok(Cli { config, command })
229 }
230
231 pub async fn run(self) -> Result<(), Box<dyn Error>> {
236 self.command.run(self.config).await
237 }
238}
239
240#[derive(Debug, Clone)]
242pub enum Commands {
243 CalendarList(CmdCalendarList),
245
246 CalendarShow(CmdCalendarShow),
248
249 Dashboard(CmdDashboard),
251
252 New(CmdNew),
254
255 Edit(CmdEdit),
257
258 Delay(CmdDelay),
260
261 Reschedule(CmdReschedule),
263
264 Flush(CmdFlush),
266
267 EventNew(CmdEventNew),
269
270 EventEdit(CmdEventEdit),
272
273 EventDelay(CmdEventDelay),
275
276 EventReschedule(CmdEventReschedule),
278
279 EventList(CmdEventList),
281
282 TodoNew(CmdTodoNew),
284
285 TodoEdit(CmdTodoEdit),
287
288 TodoUndo(CmdTodoUndo),
290
291 TodoDone(CmdTodoDone),
293
294 TodoCancel(CmdTodoCancel),
296
297 TodoDelay(CmdTodoDelay),
299
300 TodoReschedule(CmdTodoReschedule),
302
303 TodoList(CmdTodoList),
305
306 GenerateCompletion(CmdGenerateCompletion),
308}
309
310impl Commands {
311 #[rustfmt::skip]
316 #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
317 pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
318 use Commands::{
319 CalendarList, CalendarShow, Dashboard, Delay, Edit, EventDelay, EventEdit,
320 EventList, EventNew, EventReschedule, Flush, GenerateCompletion, New, Reschedule,
321 TodoCancel, TodoDelay, TodoDone, TodoEdit, TodoList, TodoNew, TodoReschedule,
322 TodoUndo,
323 };
324 tracing::info!(?self, "running command");
325 match self {
326 CalendarList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
327 CalendarShow(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
328 Dashboard(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
329 New(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
330 Edit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
331 Delay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
332 Reschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
333 Flush(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
334 EventNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
335 EventEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
336 EventDelay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
337 EventReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
338 EventList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
339 TodoNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
340 TodoEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
341 TodoUndo(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
342 TodoDone(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
343 TodoCancel(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
344 TodoDelay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
345 TodoReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
346 TodoList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
347 GenerateCompletion(a) => { a.run(); Ok(()) }
348 }
349 }
350
351 async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
352 where
353 F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
354 {
355 tracing::debug!("parsing configuration...");
356 let (core_config, _config) = parse_config(config).await?;
357
358 tracing::debug!("instantiating...");
359 let mut aim = Aim::new(core_config).await?;
360 for notice in aim.startup_notices() {
361 println!("Note: {notice}");
362 }
363
364 tracing::debug!("running command...");
365 f(&mut aim).await?;
366
367 tracing::debug!("closing...");
368 aim.close().await?;
369 Ok(())
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use aimcal_core::Id;
376
377 use crate::{cmd_generate_completion::Shell, util::OutputFormat};
378
379 use super::*;
380
381 #[test]
382 fn parses_config_command() {
383 let args = ["test", "-c", "/tmp/config.toml"];
384 let cli = Cli::try_parse_from(args).unwrap();
385 assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
386 assert!(matches!(cli.command, Commands::Dashboard(_)));
387 }
388
389 #[test]
390 fn parses_default_dashboard_command() {
391 let args = ["test"];
392 let cli = Cli::try_parse_from(args).unwrap();
393 assert!(matches!(cli.command, Commands::Dashboard(_)));
394 }
395
396 #[test]
397 fn parses_dashboard_command() {
398 let args = ["test", "dashboard"];
399 let cli = Cli::try_parse_from(args).unwrap();
400 assert!(matches!(cli.command, Commands::Dashboard(_)));
401 }
402
403 #[test]
404 fn parses_new_command() {
405 let args = ["test", "new"];
406 let cli = Cli::try_parse_from(args).unwrap();
407 assert!(matches!(cli.command, Commands::New(_)));
408 }
409
410 #[test]
411 fn parses_add_command() {
412 let args = ["test", "add"];
413 let cli = Cli::try_parse_from(args).unwrap();
414 assert!(matches!(cli.command, Commands::New(_)));
415 }
416
417 #[test]
418 fn parses_edit_command() {
419 let args = ["test", "edit", "id1"];
420 let cli = Cli::try_parse_from(args).unwrap();
421 assert!(matches!(cli.command, Commands::Edit(_)));
422 }
423
424 #[test]
425 fn parses_flush_command() {
426 let args = ["test", "flush"];
427 let cli = Cli::try_parse_from(args).unwrap();
428 assert!(matches!(cli.command, Commands::Flush(_)));
429 }
430
431 #[test]
432 fn parses_event_new_command() {
433 let cli = Cli::try_parse_from([
434 "test",
435 "event",
436 "new",
437 "a new event",
438 "--calendar",
439 "work",
440 "--start",
441 "2025-01-01 10:00",
442 "--end",
443 "2025-01-01 12:00",
444 ])
445 .unwrap();
446 assert!(matches!(cli.command, Commands::EventNew(_)));
447 }
448
449 #[test]
450 fn parses_event_add_command() {
451 let args = [
452 "test",
453 "event",
454 "add",
455 "a new event",
456 "--start",
457 "2025-01-01 10:00",
458 "--end",
459 "2025-01-01 12:00",
460 ];
461 let cli = Cli::try_parse_from(args).unwrap();
462 assert!(matches!(cli.command, Commands::EventNew(_)));
463 }
464
465 #[test]
466 fn parses_event_edit_command() {
467 let args = ["test", "event", "edit", "some_id", "-s", "new summary"];
468 let cli = Cli::try_parse_from(args).unwrap();
469 match cli.command {
470 Commands::EventEdit(cmd) => {
471 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
472 assert_eq!(cmd.summary, Some("new summary".to_string()));
473 }
474 _ => panic!("Expected EventEdit command"),
475 }
476 }
477
478 #[test]
479 fn parses_event_delay_command() {
480 let args = ["test", "event", "delay", "id1", "id2"];
481 let cli = Cli::try_parse_from(args).unwrap();
482 match cli.command {
483 Commands::EventDelay(cmd) => {
484 let expected_ids = [
485 Id::ShortIdOrUid("id1".to_string()),
486 Id::ShortIdOrUid("id2".to_string()),
487 ];
488 assert_eq!(cmd.ids, expected_ids);
489 }
490 _ => panic!("Expected EventDelay command"),
491 }
492 }
493
494 #[test]
495 fn parses_event_reschedule_command() {
496 let args = ["test", "event", "reschedule", "id1", "id2"];
497 let cli = Cli::try_parse_from(args).unwrap();
498 match cli.command {
499 Commands::EventReschedule(cmd) => {
500 let expected_ids = [
501 Id::ShortIdOrUid("id1".to_string()),
502 Id::ShortIdOrUid("id2".to_string()),
503 ];
504 assert_eq!(cmd.ids, expected_ids);
505 }
506 _ => panic!("Expected EventReschedule command"),
507 }
508 }
509
510 #[test]
511 fn parses_event_list_command() {
512 let args = ["test", "event", "list", "--output-format", "json"];
513 let cli = Cli::try_parse_from(args).unwrap();
514 match cli.command {
515 Commands::EventList(cmd) => assert_eq!(cmd.output_format, OutputFormat::Json),
516 _ => panic!("Expected EventList command"),
517 }
518 }
519
520 #[test]
521 fn parses_todo_new_command() {
522 let args = ["test", "todo", "new", "a new todo"];
523 let cli = Cli::try_parse_from(args).unwrap();
524 assert!(matches!(cli.command, Commands::TodoNew(_)));
525 }
526
527 #[test]
528 fn parses_todo_add_command() {
529 let args = ["test", "todo", "add", "a new todo"];
530 let cli = Cli::try_parse_from(args).unwrap();
531 assert!(matches!(cli.command, Commands::TodoNew(_)));
532 }
533
534 #[test]
535 fn parses_todo_edit_command() {
536 let args = ["test", "todo", "edit", "some_id", "-s", "new summary"];
537 let cli = Cli::try_parse_from(args).unwrap();
538 match cli.command {
539 Commands::TodoEdit(cmd) => {
540 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
541 assert_eq!(cmd.summary, Some("new summary".to_string()));
542 }
543 _ => panic!("Expected TodoEdit command"),
544 }
545 }
546
547 #[test]
548 fn parses_todo_undo_command() {
549 let args = ["test", "todo", "undo", "id1", "id2"];
550 let cli = Cli::try_parse_from(args).unwrap();
551 match cli.command {
552 Commands::TodoUndo(cmd) => {
553 let expected_ids = [
554 Id::ShortIdOrUid("id1".to_string()),
555 Id::ShortIdOrUid("id2".to_string()),
556 ];
557 assert_eq!(cmd.ids, expected_ids);
558 }
559 _ => panic!("Expected TodoUndo command"),
560 }
561 }
562
563 #[test]
564 fn parses_todo_done_command() {
565 let args = ["test", "todo", "done", "id1", "id2"];
566 let cli = Cli::try_parse_from(args).unwrap();
567 match cli.command {
568 Commands::TodoDone(cmd) => {
569 let expected_ids = [
570 Id::ShortIdOrUid("id1".to_string()),
571 Id::ShortIdOrUid("id2".to_string()),
572 ];
573 assert_eq!(cmd.ids, expected_ids);
574 }
575 _ => panic!("Expected TodoDone command"),
576 }
577 }
578
579 #[test]
580 fn parses_todo_cancel_command() {
581 let args = ["test", "todo", "cancel", "id1", "id2"];
582 let cli = Cli::try_parse_from(args).unwrap();
583 match cli.command {
584 Commands::TodoCancel(cmd) => {
585 let expected_ids = [
586 Id::ShortIdOrUid("id1".to_string()),
587 Id::ShortIdOrUid("id2".to_string()),
588 ];
589 assert_eq!(cmd.ids, expected_ids);
590 }
591 _ => panic!("Expected TodoDone command"),
592 }
593 }
594
595 #[test]
596 fn parses_todo_delay_command() {
597 let args = ["test", "todo", "delay", "id1", "id2", "id3"];
598 let cli = Cli::try_parse_from(args).unwrap();
599 match cli.command {
600 Commands::TodoDelay(cmd) => {
601 let expected_ids = [
602 Id::ShortIdOrUid("id1".to_string()),
603 Id::ShortIdOrUid("id2".to_string()),
604 Id::ShortIdOrUid("id3".to_string()),
605 ];
606 assert_eq!(cmd.ids, expected_ids);
607 }
608 _ => panic!("Expected TodoDelay command"),
609 }
610 }
611
612 #[test]
613 fn parses_todo_reschedule_command() {
614 let args = ["test", "todo", "reschedule", "id1", "id2"];
615 let cli = Cli::try_parse_from(args).unwrap();
616 match cli.command {
617 Commands::TodoReschedule(cmd) => {
618 let expected_ids = [
619 Id::ShortIdOrUid("id1".to_string()),
620 Id::ShortIdOrUid("id2".to_string()),
621 ];
622 assert_eq!(cmd.ids, expected_ids);
623 }
624 _ => panic!("Expected TodoReschedule command"),
625 }
626 }
627
628 #[test]
629 fn parses_todo_list_command() {
630 let args = ["test", "todo", "list", "--output-format", "json"];
631 let cli = Cli::try_parse_from(args).unwrap();
632 match cli.command {
633 Commands::TodoList(cmd) => assert_eq!(cmd.output_format, OutputFormat::Json),
634 _ => panic!("Expected TodoList command"),
635 }
636 }
637
638 #[test]
639 fn parses_calendar_list_command() {
640 let args = ["test", "calendar", "list", "--output-format", "json"];
641 let cli = Cli::try_parse_from(args).unwrap();
642 assert!(matches!(cli.command, Commands::CalendarList(_)));
643 }
644
645 #[test]
646 fn parses_calendar_show_command() {
647 let args = [
648 "test",
649 "calendar",
650 "show",
651 "work",
652 "--output-format",
653 "json",
654 ];
655 let cli = Cli::try_parse_from(args).unwrap();
656 match cli.command {
657 Commands::CalendarShow(cmd) => {
658 assert_eq!(cmd.id, "work");
659 assert_eq!(cmd.output_format, OutputFormat::Json);
660 }
661 _ => panic!("Expected CalendarShow command"),
662 }
663 }
664
665 #[test]
666 fn parses_done_command() {
667 let args = ["test", "done", "id1", "id2"];
668 let cli = Cli::try_parse_from(args).unwrap();
669 match cli.command {
670 Commands::TodoDone(cmd) => {
671 let expected_ids = [
672 Id::ShortIdOrUid("id1".to_string()),
673 Id::ShortIdOrUid("id2".to_string()),
674 ];
675 assert_eq!(cmd.ids, expected_ids);
676 }
677 _ => panic!("Expected TodoDone command"),
678 }
679 }
680
681 #[test]
682 fn parses_generate_completion_command() {
683 let args = ["test", "generate-completion", "zsh"];
684 let cli = Cli::try_parse_from(args).unwrap();
685 match cli.command {
686 Commands::GenerateCompletion(cmd) => assert_eq!(cmd.shell, Shell::Zsh),
687 _ => panic!("Expected GenerateCompletion command"),
688 }
689 }
690}