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_subscriber::EnvFilter;
12
13use crate::cmd_event::{
14 CmdEventDelay, CmdEventEdit, CmdEventList, CmdEventNew, CmdEventReschedule,
15};
16use crate::cmd_generate_completion::CmdGenerateCompletion;
17use crate::cmd_todo::{
18 CmdTodoCancel, CmdTodoDelay, CmdTodoDone, CmdTodoEdit, CmdTodoList, CmdTodoNew,
19 CmdTodoReschedule, CmdTodoUndo,
20};
21use crate::cmd_toplevel::{CmdDashboard, CmdDelay, CmdFlush, CmdReschedule};
22use crate::cmd_tui::{CmdEdit, CmdNew};
23use crate::config::parse_config;
24
25pub async fn run() -> Result<(), Box<dyn Error>> {
27 tracing_subscriber::fmt()
28 .with_env_filter(EnvFilter::from_default_env())
29 .init();
30
31 let err = match Cli::parse() {
32 Ok(cli) => match cli.run().await {
33 Ok(()) => return Ok(()),
34 Err(e) => e,
35 },
36 Err(e) => e,
37 };
38 println!("{} {}", "Error:".red(), err);
39 Ok(())
40}
41
42#[derive(Debug)]
44pub struct Cli {
45 pub config: Option<PathBuf>,
47
48 pub command: Commands,
50}
51
52impl Cli {
53 pub fn command() -> Command {
55 const STYLES: styling::Styles = styling::Styles::styled()
56 .header(styling::AnsiColor::Green.on_default().bold())
57 .usage(styling::AnsiColor::Green.on_default().bold())
58 .literal(styling::AnsiColor::Blue.on_default().bold())
59 .placeholder(styling::AnsiColor::Cyan.on_default());
60
61 Command::new(APP_NAME)
62 .about("Analyze. Interact. Manage Your Time, with calendar support.")
63 .author("Zexin Yuan <aim@yzx9.xyz>")
64 .version(crate_version!())
65 .styles(STYLES)
66 .subcommand_required(false) .arg_required_else_help(false)
68 .arg(
69 arg!(-c --config [CONFIG] "Path to the configuration file")
70 .long_help(
71 "\
72Path to the configuration file. Defaults to $XDG_CONFIG_HOME/aim/config.toml on Linux and MacOS, \
73%LOCALAPPDATA%/aim/config.toml on Windows.",
74 )
75 .value_parser(value_parser!(PathBuf))
76 .value_hint(ValueHint::FilePath),
77 )
78 .subcommand(CmdDashboard::command())
79 .subcommand(CmdNew::command())
80 .subcommand(CmdEdit::command())
81 .subcommand(CmdDelay::command())
82 .subcommand(CmdReschedule::command())
83 .subcommand(
84 Command::new("event")
85 .alias("e")
86 .about("Manage your event list")
87 .arg_required_else_help(true)
88 .subcommand_required(true)
89 .subcommand(CmdEventNew::command())
90 .subcommand(CmdEventEdit::command())
91 .subcommand(CmdEventDelay::command())
92 .subcommand(CmdEventReschedule::command())
93 .subcommand(CmdEventList::command()),
94 )
95 .subcommand(
96 Command::new("todo")
97 .alias("t")
98 .about("Manage your todo list")
99 .arg_required_else_help(true)
100 .subcommand_required(true)
101 .subcommand(CmdTodoNew::command())
102 .subcommand(CmdTodoEdit::command())
103 .subcommand(CmdTodoDone::command())
104 .subcommand(CmdTodoUndo::command())
105 .subcommand(CmdTodoCancel::command())
106 .subcommand(CmdTodoDelay::command())
107 .subcommand(CmdTodoReschedule::command())
108 .subcommand(CmdTodoList::command()),
109 )
110 .subcommand(CmdTodoDone::command())
111 .subcommand(CmdFlush::command())
112 .subcommand(CmdGenerateCompletion::command())
113 }
114
115 pub fn parse() -> Result<Self, Box<dyn Error>> {
117 let commands = Self::command();
118 let matches = commands.get_matches();
119 Self::from(matches)
120 }
121
122 pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
124 where
125 I: IntoIterator<Item = T>,
126 T: Into<OsString> + Clone,
127 {
128 let commands = Self::command();
129 let matches = commands.try_get_matches_from(args)?;
130 Self::from(matches)
131 }
132
133 pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
135 use Commands::*;
136 let command = match matches.subcommand() {
137 Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
138 Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
139 Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
140 Some((CmdDelay::NAME, matches)) => Delay(CmdDelay::from(matches)),
141 Some((CmdReschedule::NAME, matches)) => Reschedule(CmdReschedule::from(matches)),
142 Some((CmdFlush::NAME, matches)) => Flush(CmdFlush::from(matches)),
143 Some(("event", matches)) => match matches.subcommand() {
144 Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)),
145 Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
146 Some((CmdEventDelay::NAME, matches)) => EventDelay(CmdEventDelay::from(matches)),
147 Some((CmdEventReschedule::NAME, matches)) => {
148 EventReschedule(CmdEventReschedule::from(matches))
149 }
150 Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
151 _ => unreachable!(),
152 },
153 Some(("todo", matches)) => match matches.subcommand() {
154 Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)),
155 Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
156 Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
157 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
158 Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
159 Some((CmdTodoDelay::NAME, matches)) => TodoDelay(CmdTodoDelay::from(matches)),
160 Some((CmdTodoReschedule::NAME, matches)) => {
161 TodoReschedule(CmdTodoReschedule::from(matches))
162 }
163 Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
164 _ => unreachable!(),
165 },
166 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
167 Some((CmdGenerateCompletion::NAME, matches)) => {
168 GenerateCompletion(CmdGenerateCompletion::from(matches))
169 }
170 None => Dashboard(CmdDashboard),
171 _ => unreachable!(),
172 };
173
174 let config = matches.get_one("config").cloned();
175 Ok(Cli { config, command })
176 }
177
178 pub async fn run(self) -> Result<(), Box<dyn Error>> {
180 self.command.run(self.config).await
181 }
182}
183
184#[derive(Debug, Clone)]
186pub enum Commands {
187 Dashboard(CmdDashboard),
189
190 New(CmdNew),
192
193 Edit(CmdEdit),
195
196 Delay(CmdDelay),
198
199 Reschedule(CmdReschedule),
201
202 Flush(CmdFlush),
204
205 EventNew(CmdEventNew),
207
208 EventEdit(CmdEventEdit),
210
211 EventDelay(CmdEventDelay),
213
214 EventReschedule(CmdEventReschedule),
216
217 EventList(CmdEventList),
219
220 TodoNew(CmdTodoNew),
222
223 TodoEdit(CmdTodoEdit),
225
226 TodoUndo(CmdTodoUndo),
228
229 TodoDone(CmdTodoDone),
231
232 TodoCancel(CmdTodoCancel),
234
235 TodoDelay(CmdTodoDelay),
237
238 TodoReschedule(CmdTodoReschedule),
240
241 TodoList(CmdTodoList),
243
244 GenerateCompletion(CmdGenerateCompletion),
246}
247
248impl Commands {
249 #[rustfmt::skip]
251 #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
252 pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
253 use Commands::*;
254 tracing::info!(?self, "running command");
255 match self {
256 Dashboard(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
257 New(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
258 Edit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
259 Delay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
260 Reschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
261 Flush(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
262 EventNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
263 EventEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
264 EventDelay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
265 EventReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
266 EventList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
267 TodoNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
268 TodoEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
269 TodoUndo(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
270 TodoDone(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
271 TodoCancel(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
272 TodoDelay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
273 TodoReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
274 TodoList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
275 GenerateCompletion(a) => a.run(),
276 }
277 }
278
279 async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
280 where
281 F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
282 {
283 tracing::debug!("parsing configuration...");
284 let (core_config, _config) = parse_config(config).await?;
285
286 tracing::debug!("instantiating...");
287 let mut aim = Aim::new(core_config).await?;
288
289 tracing::debug!("running command...");
290 f(&mut aim).await?;
291
292 tracing::debug!("closing...");
293 aim.close().await?;
294 Ok(())
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use aimcal_core::Id;
301
302 use crate::{cmd_generate_completion::Shell, util::OutputFormat};
303
304 use super::*;
305
306 #[test]
307 fn test_parse_config() {
308 let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
309 assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
310 assert!(matches!(cli.command, Commands::Dashboard(_)));
311 }
312
313 #[test]
314 fn test_parse_default_dashboard() {
315 let cli = Cli::try_parse_from(vec!["test"]).unwrap();
316 assert!(matches!(cli.command, Commands::Dashboard(_)));
317 }
318
319 #[test]
320 fn test_parse_dashboard() {
321 let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
322 assert!(matches!(cli.command, Commands::Dashboard(_)));
323 }
324
325 #[test]
326 fn test_parse_new() {
327 let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
328 assert!(matches!(cli.command, Commands::New(_)));
329 }
330
331 #[test]
332 fn test_parse_add() {
333 let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
334 assert!(matches!(cli.command, Commands::New(_)));
335 }
336
337 #[test]
338 fn test_parse_edit() {
339 let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
340 assert!(matches!(cli.command, Commands::Edit(_)));
341 }
342
343 #[test]
344 fn test_parse_flush() {
345 let cli = Cli::try_parse_from(vec!["test", "flush"]).unwrap();
346 assert!(matches!(cli.command, Commands::Flush(_)));
347 }
348
349 #[test]
350 fn test_parse_event_new() {
351 let cli = Cli::try_parse_from(vec![
352 "test",
353 "event",
354 "new",
355 "a new event",
356 "--start",
357 "2025-01-01 10:00",
358 "--end",
359 "2025-01-01 12:00",
360 ])
361 .unwrap();
362 assert!(matches!(cli.command, Commands::EventNew(_)));
363 }
364
365 #[test]
366 fn test_parse_event_add() {
367 let cli = Cli::try_parse_from(vec![
368 "test",
369 "event",
370 "add",
371 "a new event",
372 "--start",
373 "2025-01-01 10:00",
374 "--end",
375 "2025-01-01 12:00",
376 ])
377 .unwrap();
378 assert!(matches!(cli.command, Commands::EventNew(_)));
379 }
380
381 #[test]
382 fn test_parse_event_edit() {
383 let args = vec!["test", "event", "edit", "some_id", "-s", "new summary"];
384 let cli = Cli::try_parse_from(args).unwrap();
385 match cli.command {
386 Commands::EventEdit(cmd) => {
387 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
388 assert_eq!(cmd.summary, Some("new summary".to_string()));
389 }
390 _ => panic!("Expected EventEdit command"),
391 }
392 }
393
394 #[test]
395 fn test_parse_event_delay() {
396 let cli = Cli::try_parse_from(vec!["test", "event", "delay", "id1", "id2"]).unwrap();
397 match cli.command {
398 Commands::EventDelay(cmd) => {
399 let expected_ids = vec![
400 Id::ShortIdOrUid("id1".to_string()),
401 Id::ShortIdOrUid("id2".to_string()),
402 ];
403 assert_eq!(cmd.ids, expected_ids);
404 }
405 _ => panic!("Expected EventDelay command"),
406 }
407 }
408
409 #[test]
410 fn test_parse_event_reschedule() {
411 let cli = Cli::try_parse_from(vec!["test", "event", "reschedule", "id1", "id2"]).unwrap();
412 match cli.command {
413 Commands::EventReschedule(cmd) => {
414 let expected_ids = vec![
415 Id::ShortIdOrUid("id1".to_string()),
416 Id::ShortIdOrUid("id2".to_string()),
417 ];
418 assert_eq!(cmd.ids, expected_ids);
419 }
420 _ => panic!("Expected EventReschedule command"),
421 }
422 }
423
424 #[test]
425 fn test_parse_event_list() {
426 let args = vec!["test", "event", "list", "--output-format", "json"];
427 let cli = Cli::try_parse_from(args).unwrap();
428 match cli.command {
429 Commands::EventList(cmd) => {
430 assert_eq!(cmd.output_format, OutputFormat::Json);
431 }
432 _ => panic!("Expected EventList command"),
433 }
434 }
435
436 #[test]
437 fn test_parse_todo_new() {
438 let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
439 assert!(matches!(cli.command, Commands::TodoNew(_)));
440 }
441
442 #[test]
443 fn test_parse_todo_add() {
444 let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
445 assert!(matches!(cli.command, Commands::TodoNew(_)));
446 }
447
448 #[test]
449 fn test_parse_todo_edit() {
450 let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
451 let cli = Cli::try_parse_from(args).unwrap();
452 match cli.command {
453 Commands::TodoEdit(cmd) => {
454 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
455 assert_eq!(cmd.summary, Some("new summary".to_string()));
456 }
457 _ => panic!("Expected TodoEdit command"),
458 }
459 }
460
461 #[test]
462 fn test_parse_todo_undo() {
463 let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1", "id2"]).unwrap();
464 match cli.command {
465 Commands::TodoUndo(cmd) => {
466 let expected_ids = vec![
467 Id::ShortIdOrUid("id1".to_string()),
468 Id::ShortIdOrUid("id2".to_string()),
469 ];
470 assert_eq!(cmd.ids, expected_ids);
471 }
472 _ => panic!("Expected TodoUndo command"),
473 }
474 }
475
476 #[test]
477 fn test_parse_todo_done() {
478 let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
479 match cli.command {
480 Commands::TodoDone(cmd) => {
481 let expected_ids = vec![
482 Id::ShortIdOrUid("id1".to_string()),
483 Id::ShortIdOrUid("id2".to_string()),
484 ];
485 assert_eq!(cmd.ids, expected_ids);
486 }
487 _ => panic!("Expected TodoDone command"),
488 }
489 }
490
491 #[test]
492 fn test_parse_todo_cancel() {
493 let cli = Cli::try_parse_from(vec!["test", "todo", "cancel", "id1", "id2"]).unwrap();
494 match cli.command {
495 Commands::TodoCancel(cmd) => {
496 let expected_ids = vec![
497 Id::ShortIdOrUid("id1".to_string()),
498 Id::ShortIdOrUid("id2".to_string()),
499 ];
500 assert_eq!(cmd.ids, expected_ids);
501 }
502 _ => panic!("Expected TodoDone command"),
503 }
504 }
505
506 #[test]
507 fn test_parse_todo_delay() {
508 let cli = Cli::try_parse_from(vec!["test", "todo", "delay", "id1", "id2", "id3"]).unwrap();
509 match cli.command {
510 Commands::TodoDelay(cmd) => {
511 let expected_ids = vec![
512 Id::ShortIdOrUid("id1".to_string()),
513 Id::ShortIdOrUid("id2".to_string()),
514 Id::ShortIdOrUid("id3".to_string()),
515 ];
516 assert_eq!(cmd.ids, expected_ids);
517 }
518 _ => panic!("Expected TodoDelay command"),
519 }
520 }
521
522 #[test]
523 fn test_parse_todo_reschedule() {
524 let cli = Cli::try_parse_from(vec!["test", "todo", "reschedule", "id1", "id2"]).unwrap();
525 match cli.command {
526 Commands::TodoReschedule(cmd) => {
527 let expected_ids = vec![
528 Id::ShortIdOrUid("id1".to_string()),
529 Id::ShortIdOrUid("id2".to_string()),
530 ];
531 assert_eq!(cmd.ids, expected_ids);
532 }
533 _ => panic!("Expected TodoReschedule command"),
534 }
535 }
536
537 #[test]
538 fn test_parse_todo_list() {
539 let args = vec!["test", "todo", "list", "--output-format", "json"];
540 let cli = Cli::try_parse_from(args).unwrap();
541 match cli.command {
542 Commands::TodoList(cmd) => {
543 assert_eq!(cmd.output_format, OutputFormat::Json);
544 }
545 _ => panic!("Expected TodoList command"),
546 }
547 }
548
549 #[test]
550 fn test_parse_done() {
551 let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
552 match cli.command {
553 Commands::TodoDone(cmd) => {
554 assert_eq!(
555 cmd.ids,
556 vec![
557 Id::ShortIdOrUid("id1".to_string()),
558 Id::ShortIdOrUid("id2".to_string())
559 ]
560 );
561 }
562 _ => panic!("Expected TodoDone command"),
563 }
564 }
565
566 #[test]
567 fn test_parse_generate_completions() {
568 let args = vec!["test", "generate-completion", "zsh"];
569 let cli = Cli::try_parse_from(args).unwrap();
570 match cli.command {
571 Commands::GenerateCompletion(cmd) => {
572 assert_eq!(cmd.shell, Shell::Zsh);
573 }
574 _ => panic!("Expected GenerateCompletion command"),
575 }
576 }
577}