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