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, 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(CmdGenerateCompletion::command())
112 }
113
114 pub fn parse() -> Result<Self, Box<dyn Error>> {
116 let commands = Self::command();
117 let matches = commands.get_matches();
118 Self::from(matches)
119 }
120
121 pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
123 where
124 I: IntoIterator<Item = T>,
125 T: Into<OsString> + Clone,
126 {
127 let commands = Self::command();
128 let matches = commands.try_get_matches_from(args)?;
129 Self::from(matches)
130 }
131
132 pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
134 use Commands::*;
135 let command = match matches.subcommand() {
136 Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
137 Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
138 Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
139 Some((CmdDelay::NAME, matches)) => Delay(CmdDelay::from(matches)),
140 Some((CmdReschedule::NAME, matches)) => Reschedule(CmdReschedule::from(matches)),
141 Some(("event", matches)) => match matches.subcommand() {
142 Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)),
143 Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
144 Some((CmdEventDelay::NAME, matches)) => EventDelay(CmdEventDelay::from(matches)),
145 Some((CmdEventReschedule::NAME, matches)) => {
146 EventReschedule(CmdEventReschedule::from(matches))
147 }
148 Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
149 _ => unreachable!(),
150 },
151 Some(("todo", matches)) => match matches.subcommand() {
152 Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)),
153 Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
154 Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
155 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
156 Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
157 Some((CmdTodoDelay::NAME, matches)) => TodoDelay(CmdTodoDelay::from(matches)),
158 Some((CmdTodoReschedule::NAME, matches)) => {
159 TodoReschedule(CmdTodoReschedule::from(matches))
160 }
161 Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
162 _ => unreachable!(),
163 },
164 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
165 Some((CmdGenerateCompletion::NAME, matches)) => {
166 GenerateCompletion(CmdGenerateCompletion::from(matches))
167 }
168 None => Dashboard(CmdDashboard),
169 _ => unreachable!(),
170 };
171
172 let config = matches.get_one("config").cloned();
173 Ok(Cli { config, command })
174 }
175
176 pub async fn run(self) -> Result<(), Box<dyn Error>> {
178 self.command.run(self.config).await
179 }
180}
181
182#[derive(Debug, Clone)]
184pub enum Commands {
185 Dashboard(CmdDashboard),
187
188 New(CmdNew),
190
191 Edit(CmdEdit),
193
194 Delay(CmdDelay),
196
197 Reschedule(CmdReschedule),
199
200 EventNew(CmdEventNew),
202
203 EventEdit(CmdEventEdit),
205
206 EventDelay(CmdEventDelay),
208
209 EventReschedule(CmdEventReschedule),
211
212 EventList(CmdEventList),
214
215 TodoNew(CmdTodoNew),
217
218 TodoEdit(CmdTodoEdit),
220
221 TodoUndo(CmdTodoUndo),
223
224 TodoDone(CmdTodoDone),
226
227 TodoCancel(CmdTodoCancel),
229
230 TodoDelay(CmdTodoDelay),
232
233 TodoReschedule(CmdTodoReschedule),
235
236 TodoList(CmdTodoList),
238
239 GenerateCompletion(CmdGenerateCompletion),
241}
242
243impl Commands {
244 #[rustfmt::skip]
246 #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
247 pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
248 use Commands::*;
249 tracing::info!(?self, "running command");
250 match self {
251 Dashboard(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
252 New(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
253 Edit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
254 Delay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
255 Reschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
256 EventNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
257 EventEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
258 EventDelay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
259 EventReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
260 EventList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
261 TodoNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
262 TodoEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
263 TodoUndo(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
264 TodoDone(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
265 TodoCancel(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
266 TodoDelay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
267 TodoReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
268 TodoList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
269 GenerateCompletion(a) => a.run(),
270 }
271 }
272
273 async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
274 where
275 F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
276 {
277 tracing::debug!("parsing configuration...");
278 let (core_config, _config) = parse_config(config).await?;
279
280 tracing::debug!("instantiating...");
281 let mut aim = Aim::new(core_config).await?;
282
283 tracing::debug!("running command...");
284 f(&mut aim).await?;
285
286 tracing::debug!("closing...");
287 aim.close().await?;
288 Ok(())
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use aimcal_core::Id;
295
296 use crate::{cmd_generate_completion::Shell, util::OutputFormat};
297
298 use super::*;
299
300 #[test]
301 fn test_parse_config() {
302 let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
303 assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
304 assert!(matches!(cli.command, Commands::Dashboard(_)));
305 }
306
307 #[test]
308 fn test_parse_default_dashboard() {
309 let cli = Cli::try_parse_from(vec!["test"]).unwrap();
310 assert!(matches!(cli.command, Commands::Dashboard(_)));
311 }
312
313 #[test]
314 fn test_parse_dashboard() {
315 let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
316 assert!(matches!(cli.command, Commands::Dashboard(_)));
317 }
318
319 #[test]
320 fn test_parse_new() {
321 let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
322 assert!(matches!(cli.command, Commands::New(_)));
323 }
324
325 #[test]
326 fn test_parse_add() {
327 let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
328 assert!(matches!(cli.command, Commands::New(_)));
329 }
330
331 #[test]
332 fn test_parse_edit() {
333 let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
334 assert!(matches!(cli.command, Commands::Edit(_)));
335 }
336
337 #[test]
338 fn test_parse_event_new() {
339 let cli = Cli::try_parse_from(vec![
340 "test",
341 "event",
342 "new",
343 "a new event",
344 "--start",
345 "2025-01-01 10:00",
346 "--end",
347 "2025-01-01 12:00",
348 ])
349 .unwrap();
350 assert!(matches!(cli.command, Commands::EventNew(_)));
351 }
352
353 #[test]
354 fn test_parse_event_add() {
355 let cli = Cli::try_parse_from(vec![
356 "test",
357 "event",
358 "add",
359 "a new event",
360 "--start",
361 "2025-01-01 10:00",
362 "--end",
363 "2025-01-01 12:00",
364 ])
365 .unwrap();
366 assert!(matches!(cli.command, Commands::EventNew(_)));
367 }
368
369 #[test]
370 fn test_parse_event_edit() {
371 let args = vec!["test", "event", "edit", "some_id", "-s", "new summary"];
372 let cli = Cli::try_parse_from(args).unwrap();
373 match cli.command {
374 Commands::EventEdit(cmd) => {
375 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
376 assert_eq!(cmd.summary, Some("new summary".to_string()));
377 }
378 _ => panic!("Expected EventEdit command"),
379 }
380 }
381
382 #[test]
383 fn test_parse_event_delay() {
384 let cli = Cli::try_parse_from(vec![
385 "test", "event", "delay", "id1", "id2", "--time", "time",
386 ])
387 .unwrap();
388 match cli.command {
389 Commands::EventDelay(cmd) => {
390 let expected_ids = vec![
391 Id::ShortIdOrUid("id1".to_string()),
392 Id::ShortIdOrUid("id2".to_string()),
393 ];
394 assert_eq!(cmd.ids, expected_ids);
395 assert_eq!(cmd.time_anchor, "time".to_string());
396 }
397 _ => panic!("Expected EventDelay command"),
398 }
399 }
400
401 #[test]
402 fn test_parse_event_reschedule() {
403 let cli = Cli::try_parse_from(vec![
404 "test",
405 "event",
406 "reschedule",
407 "id1",
408 "id2",
409 "--time",
410 "time",
411 ])
412 .unwrap();
413 match cli.command {
414 Commands::EventReschedule(cmd) => {
415 let expected_ids = vec![
416 Id::ShortIdOrUid("id1".to_string()),
417 Id::ShortIdOrUid("id2".to_string()),
418 ];
419 assert_eq!(cmd.ids, expected_ids);
420 assert_eq!(cmd.time_anchor, "time".to_string());
421 }
422 _ => panic!("Expected EventReschedule command"),
423 }
424 }
425
426 #[test]
427 fn test_parse_event_list() {
428 let args = vec!["test", "event", "list", "--output-format", "json"];
429 let cli = Cli::try_parse_from(args).unwrap();
430 match cli.command {
431 Commands::EventList(cmd) => {
432 assert_eq!(cmd.output_format, OutputFormat::Json);
433 }
434 _ => panic!("Expected EventList command"),
435 }
436 }
437
438 #[test]
439 fn test_parse_todo_new() {
440 let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
441 assert!(matches!(cli.command, Commands::TodoNew(_)));
442 }
443
444 #[test]
445 fn test_parse_todo_add() {
446 let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
447 assert!(matches!(cli.command, Commands::TodoNew(_)));
448 }
449
450 #[test]
451 fn test_parse_todo_edit() {
452 let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
453 let cli = Cli::try_parse_from(args).unwrap();
454 match cli.command {
455 Commands::TodoEdit(cmd) => {
456 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
457 assert_eq!(cmd.summary, Some("new summary".to_string()));
458 }
459 _ => panic!("Expected TodoEdit command"),
460 }
461 }
462
463 #[test]
464 fn test_parse_todo_undo() {
465 let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1", "id2"]).unwrap();
466 match cli.command {
467 Commands::TodoUndo(cmd) => {
468 let expected_ids = vec![
469 Id::ShortIdOrUid("id1".to_string()),
470 Id::ShortIdOrUid("id2".to_string()),
471 ];
472 assert_eq!(cmd.ids, expected_ids);
473 }
474 _ => panic!("Expected TodoUndo command"),
475 }
476 }
477
478 #[test]
479 fn test_parse_todo_done() {
480 let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
481 match cli.command {
482 Commands::TodoDone(cmd) => {
483 let expected_ids = vec![
484 Id::ShortIdOrUid("id1".to_string()),
485 Id::ShortIdOrUid("id2".to_string()),
486 ];
487 assert_eq!(cmd.ids, expected_ids);
488 }
489 _ => panic!("Expected TodoDone command"),
490 }
491 }
492
493 #[test]
494 fn test_parse_todo_cancel() {
495 let cli = Cli::try_parse_from(vec!["test", "todo", "cancel", "id1", "id2"]).unwrap();
496 match cli.command {
497 Commands::TodoCancel(cmd) => {
498 let expected_ids = vec![
499 Id::ShortIdOrUid("id1".to_string()),
500 Id::ShortIdOrUid("id2".to_string()),
501 ];
502 assert_eq!(cmd.ids, expected_ids);
503 }
504 _ => panic!("Expected TodoDone command"),
505 }
506 }
507
508 #[test]
509 fn test_parse_todo_delay() {
510 let cli = Cli::try_parse_from(vec![
511 "test", "todo", "delay", "id1", "id2", "id3", "--time", "time",
512 ])
513 .unwrap();
514 match cli.command {
515 Commands::TodoDelay(cmd) => {
516 let expected_ids = vec![
517 Id::ShortIdOrUid("id1".to_string()),
518 Id::ShortIdOrUid("id2".to_string()),
519 Id::ShortIdOrUid("id3".to_string()),
520 ];
521 assert_eq!(cmd.ids, expected_ids);
522 assert_eq!(cmd.time, "time".to_string());
523 }
524 _ => panic!("Expected TodoDelay command"),
525 }
526 }
527
528 #[test]
529 fn test_parse_todo_reschedule() {
530 let cli = Cli::try_parse_from(vec![
531 "test",
532 "todo",
533 "reschedule",
534 "id1",
535 "id2",
536 "--time",
537 "time",
538 ])
539 .unwrap();
540 match cli.command {
541 Commands::TodoReschedule(cmd) => {
542 let expected_ids = vec![
543 Id::ShortIdOrUid("id1".to_string()),
544 Id::ShortIdOrUid("id2".to_string()),
545 ];
546 assert_eq!(cmd.ids, expected_ids);
547 assert_eq!(cmd.time, "time".to_string());
548 }
549 _ => panic!("Expected TodoReschedule command"),
550 }
551 }
552
553 #[test]
554 fn test_parse_todo_list() {
555 let args = vec!["test", "todo", "list", "--output-format", "json"];
556 let cli = Cli::try_parse_from(args).unwrap();
557 match cli.command {
558 Commands::TodoList(cmd) => {
559 assert_eq!(cmd.output_format, OutputFormat::Json);
560 }
561 _ => panic!("Expected TodoList command"),
562 }
563 }
564
565 #[test]
566 fn test_parse_done() {
567 let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
568 match cli.command {
569 Commands::TodoDone(cmd) => {
570 assert_eq!(
571 cmd.ids,
572 vec![
573 Id::ShortIdOrUid("id1".to_string()),
574 Id::ShortIdOrUid("id2".to_string())
575 ]
576 );
577 }
578 _ => panic!("Expected TodoDone command"),
579 }
580 }
581
582 #[test]
583 fn test_parse_generate_completions() {
584 let args = vec!["test", "generate-completion", "zsh"];
585 let cli = Cli::try_parse_from(args).unwrap();
586 match cli.command {
587 Commands::GenerateCompletion(cmd) => {
588 assert_eq!(cmd.shell, Shell::Zsh);
589 }
590 _ => panic!("Expected GenerateCompletion command"),
591 }
592 }
593}