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_dashboard::CmdDashboard;
14use crate::cmd_event::{CmdEventEdit, CmdEventList, CmdEventNew};
15use crate::cmd_generate_completion::CmdGenerateCompletion;
16use crate::cmd_todo::{
17 CmdTodoCancel, CmdTodoDelay, CmdTodoDone, CmdTodoEdit, CmdTodoList, CmdTodoNew, CmdTodoUndo,
18};
19use crate::cmd_tui::{CmdEdit, CmdNew};
20use crate::config::parse_config;
21
22pub async fn run() -> Result<(), Box<dyn Error>> {
24 tracing_subscriber::fmt()
25 .with_env_filter(EnvFilter::from_default_env())
26 .init();
27
28 let err = match Cli::parse() {
29 Ok(cli) => match cli.run().await {
30 Ok(()) => return Ok(()),
31 Err(e) => e,
32 },
33 Err(e) => e,
34 };
35 println!("{} {}", "Error:".red(), err);
36 Ok(())
37}
38
39#[derive(Debug)]
41pub struct Cli {
42 pub config: Option<PathBuf>,
44
45 pub command: Commands,
47}
48
49impl Cli {
50 pub fn command() -> Command {
52 const STYLES: styling::Styles = styling::Styles::styled()
53 .header(styling::AnsiColor::Green.on_default().bold())
54 .usage(styling::AnsiColor::Green.on_default().bold())
55 .literal(styling::AnsiColor::Blue.on_default().bold())
56 .placeholder(styling::AnsiColor::Cyan.on_default());
57
58 Command::new(APP_NAME)
59 .about("Analyze. Interact. Manage Your Time, with calendar support.")
60 .author("Zexin Yuan <aim@yzx9.xyz>")
61 .version(crate_version!())
62 .styles(STYLES)
63 .subcommand_required(false) .arg_required_else_help(false)
65 .arg(
66 arg!(-c --config [CONFIG] "Path to the configuration file")
67 .long_help(
68 "\
69Path to the configuration file. Defaults to $XDG_CONFIG_HOME/aim/config.toml on Linux and MacOS, \
70%LOCALAPPDATA%/aim/config.toml on Windows.",
71 )
72 .value_parser(value_parser!(PathBuf))
73 .value_hint(ValueHint::FilePath),
74 )
75 .subcommand(CmdDashboard::command())
76 .subcommand(CmdNew::command())
77 .subcommand(CmdEdit::command())
78 .subcommand(
79 Command::new("event")
80 .alias("e")
81 .about("Manage your event list")
82 .arg_required_else_help(true)
83 .subcommand_required(true)
84 .subcommand(CmdEventNew::command())
85 .subcommand(CmdEventEdit::command())
86 .subcommand(CmdEventList::command()),
87 )
88 .subcommand(
89 Command::new("todo")
90 .alias("t")
91 .about("Manage your todo list")
92 .arg_required_else_help(true)
93 .subcommand_required(true)
94 .subcommand(CmdTodoNew::command())
95 .subcommand(CmdTodoEdit::command())
96 .subcommand(CmdTodoDone::command())
97 .subcommand(CmdTodoUndo::command())
98 .subcommand(CmdTodoCancel::command())
99 .subcommand(CmdTodoDelay::command())
100 .subcommand(CmdTodoList::command()),
101 )
102 .subcommand(CmdTodoDone::command())
103 .subcommand(CmdGenerateCompletion::command())
104 }
105
106 pub fn parse() -> Result<Self, Box<dyn Error>> {
108 let commands = Self::command();
109 let matches = commands.get_matches();
110 Self::from(matches)
111 }
112
113 pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
115 where
116 I: IntoIterator<Item = T>,
117 T: Into<OsString> + Clone,
118 {
119 let commands = Self::command();
120 let matches = commands.try_get_matches_from(args)?;
121 Self::from(matches)
122 }
123
124 pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
126 use Commands::*;
127 let command = match matches.subcommand() {
128 Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
129 Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
130 Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
131 Some(("event", matches)) => match matches.subcommand() {
132 Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)?),
133 Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
134 Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
135 _ => unreachable!(),
136 },
137 Some(("todo", matches)) => match matches.subcommand() {
138 Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)?),
139 Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
140 Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
141 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
142 Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
143 Some((CmdTodoDelay::NAME, matches)) => TodoDelay(CmdTodoDelay::from(matches)),
144 Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
145 _ => unreachable!(),
146 },
147 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
148 Some((CmdGenerateCompletion::NAME, matches)) => {
149 GenerateCompletion(CmdGenerateCompletion::from(matches))
150 }
151 None => Dashboard(CmdDashboard),
152 _ => unreachable!(),
153 };
154
155 let config = matches.get_one("config").cloned();
156 Ok(Cli { config, command })
157 }
158
159 pub async fn run(self) -> Result<(), Box<dyn Error>> {
161 self.command.run(self.config).await
162 }
163}
164
165#[derive(Debug, Clone)]
167pub enum Commands {
168 Dashboard(CmdDashboard),
170
171 New(CmdNew),
173
174 Edit(CmdEdit),
176
177 EventNew(CmdEventNew),
179
180 EventEdit(CmdEventEdit),
182
183 EventList(CmdEventList),
185
186 TodoNew(CmdTodoNew),
188
189 TodoEdit(CmdTodoEdit),
191
192 TodoUndo(CmdTodoUndo),
194
195 TodoDone(CmdTodoDone),
197
198 TodoCancel(CmdTodoCancel),
200
201 TodoDelay(CmdTodoDelay),
203
204 TodoList(CmdTodoList),
206
207 GenerateCompletion(CmdGenerateCompletion),
209}
210
211impl Commands {
212 #[rustfmt::skip]
214 #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
215 pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
216 use Commands::*;
217 tracing::info!(?self, "running command");
218 match self {
219 Dashboard(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
220 New(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
221 Edit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
222 EventNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
223 EventEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
224 EventList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
225 TodoNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
226 TodoEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
227 TodoUndo(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
228 TodoDone(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
229 TodoCancel(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
230 TodoDelay(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
231 TodoList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
232 GenerateCompletion(a) => a.run(),
233 }
234 }
235
236 async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
237 where
238 F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
239 {
240 tracing::debug!("parsing configuration...");
241 let (core_config, _config) = parse_config(config).await?;
242
243 tracing::debug!("instantiating...");
244 let mut aim = Aim::new(core_config).await?;
245
246 tracing::debug!("running command...");
247 f(&mut aim).await?;
248
249 tracing::debug!("closing...");
250 aim.close().await?;
251 Ok(())
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::{cmd_generate_completion::Shell, util::ArgOutputFormat};
259 use aimcal_core::Id;
260
261 #[test]
262 fn test_parse_config() {
263 let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
264 assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
265 assert!(matches!(cli.command, Commands::Dashboard(_)));
266 }
267
268 #[test]
269 fn test_parse_default_dashboard() {
270 let cli = Cli::try_parse_from(vec!["test"]).unwrap();
271 assert!(matches!(cli.command, Commands::Dashboard(_)));
272 }
273
274 #[test]
275 fn test_parse_dashboard() {
276 let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
277 assert!(matches!(cli.command, Commands::Dashboard(_)));
278 }
279
280 #[test]
281 fn test_parse_new() {
282 let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
283 assert!(matches!(cli.command, Commands::New(_)));
284 }
285
286 #[test]
287 fn test_parse_add() {
288 let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
289 assert!(matches!(cli.command, Commands::New(_)));
290 }
291
292 #[test]
293 fn test_parse_edit() {
294 let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
295 assert!(matches!(cli.command, Commands::Edit(_)));
296 }
297
298 #[test]
299 fn test_parse_event_new() {
300 let cli = Cli::try_parse_from(vec![
301 "test",
302 "event",
303 "new",
304 "a new event",
305 "--start",
306 "2025-01-01 10:00",
307 "--end",
308 "2025-01-01 12:00",
309 ])
310 .unwrap();
311 assert!(matches!(cli.command, Commands::EventNew(_)));
312 }
313
314 #[test]
315 fn test_parse_event_add() {
316 let cli = Cli::try_parse_from(vec![
317 "test",
318 "event",
319 "add",
320 "a new event",
321 "--start",
322 "2025-01-01 10:00",
323 "--end",
324 "2025-01-01 12:00",
325 ])
326 .unwrap();
327 assert!(matches!(cli.command, Commands::EventNew(_)));
328 }
329
330 #[test]
331 fn test_parse_event_edit() {
332 let args = vec!["test", "event", "edit", "some_id", "-s", "new summary"];
333 let cli = Cli::try_parse_from(args).unwrap();
334 match cli.command {
335 Commands::EventEdit(cmd) => {
336 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
337 assert_eq!(cmd.summary, Some("new summary".to_string()));
338 }
339 _ => panic!("Expected EventEdit command"),
340 }
341 }
342
343 #[test]
344 fn test_parse_event_list() {
345 let args = vec!["test", "event", "list", "--output-format", "json"];
346 let cli = Cli::try_parse_from(args).unwrap();
347 match cli.command {
348 Commands::EventList(cmd) => {
349 assert_eq!(cmd.output_format, ArgOutputFormat::Json);
350 }
351 _ => panic!("Expected EventList command"),
352 }
353 }
354
355 #[test]
356 fn test_parse_todo_new() {
357 let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
358 assert!(matches!(cli.command, Commands::TodoNew(_)));
359 }
360
361 #[test]
362 fn test_parse_todo_add() {
363 let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
364 assert!(matches!(cli.command, Commands::TodoNew(_)));
365 }
366
367 #[test]
368 fn test_parse_todo_edit() {
369 let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
370 let cli = Cli::try_parse_from(args).unwrap();
371 match cli.command {
372 Commands::TodoEdit(cmd) => {
373 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
374 assert_eq!(cmd.summary, Some("new summary".to_string()));
375 }
376 _ => panic!("Expected TodoEdit command"),
377 }
378 }
379
380 #[test]
381 fn test_parse_todo_undo() {
382 let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1", "id2"]).unwrap();
383 match cli.command {
384 Commands::TodoUndo(cmd) => {
385 assert_eq!(
386 cmd.ids,
387 vec![
388 Id::ShortIdOrUid("id1".to_string()),
389 Id::ShortIdOrUid("id2".to_string())
390 ]
391 );
392 }
393 _ => panic!("Expected TodoUndo command"),
394 }
395 }
396
397 #[test]
398 fn test_parse_todo_done() {
399 let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
400 match cli.command {
401 Commands::TodoDone(cmd) => {
402 assert_eq!(
403 cmd.ids,
404 vec![
405 Id::ShortIdOrUid("id1".to_string()),
406 Id::ShortIdOrUid("id2".to_string())
407 ]
408 );
409 }
410 _ => panic!("Expected TodoDone command"),
411 }
412 }
413
414 #[test]
415 fn test_parse_todo_cancel() {
416 let cli = Cli::try_parse_from(vec!["test", "todo", "cancel", "id1", "id2"]).unwrap();
417 match cli.command {
418 Commands::TodoCancel(cmd) => {
419 assert_eq!(
420 cmd.ids,
421 vec![
422 Id::ShortIdOrUid("id1".to_string()),
423 Id::ShortIdOrUid("id2".to_string())
424 ]
425 );
426 }
427 _ => panic!("Expected TodoDone command"),
428 }
429 }
430
431 #[test]
432 fn test_parse_todo_list() {
433 let args = vec!["test", "todo", "list", "--output-format", "json"];
434 let cli = Cli::try_parse_from(args).unwrap();
435 match cli.command {
436 Commands::TodoList(cmd) => {
437 assert_eq!(cmd.output_format, ArgOutputFormat::Json);
438 }
439 _ => panic!("Expected TodoList command"),
440 }
441 }
442
443 #[test]
444 fn test_parse_done() {
445 let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
446 match cli.command {
447 Commands::TodoDone(cmd) => {
448 assert_eq!(
449 cmd.ids,
450 vec![
451 Id::ShortIdOrUid("id1".to_string()),
452 Id::ShortIdOrUid("id2".to_string())
453 ]
454 );
455 }
456 _ => panic!("Expected TodoDone command"),
457 }
458 }
459
460 #[test]
461 fn test_parse_generate_completions() {
462 let args = vec!["test", "generate-completion", "zsh"];
463 let cli = Cli::try_parse_from(args).unwrap();
464 match cli.command {
465 Commands::GenerateCompletion(cmd) => {
466 assert_eq!(cmd.shell, Shell::Zsh);
467 }
468 _ => panic!("Expected GenerateCompletion command"),
469 }
470 }
471}