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, 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(CmdTodoList::command()),
100 )
101 .subcommand(CmdTodoDone::command())
102 .subcommand(CmdGenerateCompletion::command())
103 }
104
105 pub fn parse() -> Result<Self, Box<dyn Error>> {
107 let commands = Self::command();
108 let matches = commands.get_matches();
109 Self::from(matches)
110 }
111
112 pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
114 where
115 I: IntoIterator<Item = T>,
116 T: Into<OsString> + Clone,
117 {
118 let commands = Self::command();
119 let matches = commands.try_get_matches_from(args)?;
120 Self::from(matches)
121 }
122
123 pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
125 use Commands::*;
126 let command = match matches.subcommand() {
127 Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
128 Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
129 Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
130 Some(("event", matches)) => match matches.subcommand() {
131 Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)?),
132 Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
133 Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
134 _ => unreachable!(),
135 },
136 Some(("todo", matches)) => match matches.subcommand() {
137 Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)?),
138 Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
139 Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
140 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
141 Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
142 Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
143 _ => unreachable!(),
144 },
145 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
146 Some((CmdGenerateCompletion::NAME, matches)) => {
147 GenerateCompletion(CmdGenerateCompletion::from(matches))
148 }
149 None => Dashboard(CmdDashboard),
150 _ => unreachable!(),
151 };
152
153 let config = matches.get_one("config").cloned();
154 Ok(Cli { config, command })
155 }
156
157 pub async fn run(self) -> Result<(), Box<dyn Error>> {
159 self.command.run(self.config).await
160 }
161}
162
163#[derive(Debug, Clone)]
165pub enum Commands {
166 Dashboard(CmdDashboard),
168
169 New(CmdNew),
171
172 Edit(CmdEdit),
174
175 EventNew(CmdEventNew),
177
178 EventEdit(CmdEventEdit),
180
181 EventList(CmdEventList),
183
184 TodoNew(CmdTodoNew),
186
187 TodoEdit(CmdTodoEdit),
189
190 TodoUndo(CmdTodoUndo),
192
193 TodoDone(CmdTodoDone),
195
196 TodoCancel(CmdTodoCancel),
198
199 TodoList(CmdTodoList),
201
202 GenerateCompletion(CmdGenerateCompletion),
204}
205
206impl Commands {
207 #[rustfmt::skip]
209 #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
210 pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
211 use Commands::*;
212 tracing::info!(?self, "running command");
213 match self {
214 Dashboard(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
215 New(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
216 Edit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
217 EventNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
218 EventEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
219 EventList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
220 TodoNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
221 TodoEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
222 TodoUndo(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
223 TodoDone(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
224 TodoCancel(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
225 TodoList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
226 GenerateCompletion(a) => a.run(),
227 }
228 }
229
230 async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
231 where
232 F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
233 {
234 tracing::debug!("parsing configuration...");
235 let (core_config, _config) = parse_config(config).await?;
236
237 tracing::debug!("instantiating...");
238 let mut aim = Aim::new(core_config).await?;
239
240 tracing::debug!("running command...");
241 f(&mut aim).await?;
242
243 tracing::debug!("closing...");
244 aim.close().await?;
245 Ok(())
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::{cmd_generate_completion::Shell, util::ArgOutputFormat};
253 use aimcal_core::Id;
254
255 #[test]
256 fn test_parse_config() {
257 let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
258 assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
259 assert!(matches!(cli.command, Commands::Dashboard(_)));
260 }
261
262 #[test]
263 fn test_parse_default_dashboard() {
264 let cli = Cli::try_parse_from(vec!["test"]).unwrap();
265 assert!(matches!(cli.command, Commands::Dashboard(_)));
266 }
267
268 #[test]
269 fn test_parse_dashboard() {
270 let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
271 assert!(matches!(cli.command, Commands::Dashboard(_)));
272 }
273
274 #[test]
275 fn test_parse_new() {
276 let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
277 assert!(matches!(cli.command, Commands::New(_)));
278 }
279
280 #[test]
281 fn test_parse_add() {
282 let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
283 assert!(matches!(cli.command, Commands::New(_)));
284 }
285
286 #[test]
287 fn test_parse_edit() {
288 let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
289 assert!(matches!(cli.command, Commands::Edit(_)));
290 }
291
292 #[test]
293 fn test_parse_event_new() {
294 let cli = Cli::try_parse_from(vec![
295 "test",
296 "event",
297 "new",
298 "a new event",
299 "--start",
300 "2025-01-01 10:00",
301 "--end",
302 "2025-01-01 12:00",
303 ])
304 .unwrap();
305 assert!(matches!(cli.command, Commands::EventNew(_)));
306 }
307
308 #[test]
309 fn test_parse_event_add() {
310 let cli = Cli::try_parse_from(vec![
311 "test",
312 "event",
313 "add",
314 "a new event",
315 "--start",
316 "2025-01-01 10:00",
317 "--end",
318 "2025-01-01 12:00",
319 ])
320 .unwrap();
321 assert!(matches!(cli.command, Commands::EventNew(_)));
322 }
323
324 #[test]
325 fn test_parse_event_edit() {
326 let args = vec!["test", "event", "edit", "some_id", "-s", "new summary"];
327 let cli = Cli::try_parse_from(args).unwrap();
328 match cli.command {
329 Commands::EventEdit(cmd) => {
330 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
331 assert_eq!(cmd.summary, Some("new summary".to_string()));
332 }
333 _ => panic!("Expected EventEdit command"),
334 }
335 }
336
337 #[test]
338 fn test_parse_event_list() {
339 let args = vec!["test", "event", "list", "--output-format", "json"];
340 let cli = Cli::try_parse_from(args).unwrap();
341 match cli.command {
342 Commands::EventList(cmd) => {
343 assert_eq!(cmd.output_format, ArgOutputFormat::Json);
344 }
345 _ => panic!("Expected EventList command"),
346 }
347 }
348
349 #[test]
350 fn test_parse_todo_new() {
351 let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
352 assert!(matches!(cli.command, Commands::TodoNew(_)));
353 }
354
355 #[test]
356 fn test_parse_todo_add() {
357 let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
358 assert!(matches!(cli.command, Commands::TodoNew(_)));
359 }
360
361 #[test]
362 fn test_parse_todo_edit() {
363 let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
364 let cli = Cli::try_parse_from(args).unwrap();
365 match cli.command {
366 Commands::TodoEdit(cmd) => {
367 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
368 assert_eq!(cmd.summary, Some("new summary".to_string()));
369 }
370 _ => panic!("Expected TodoEdit command"),
371 }
372 }
373
374 #[test]
375 fn test_parse_todo_undo() {
376 let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1", "id2"]).unwrap();
377 match cli.command {
378 Commands::TodoUndo(cmd) => {
379 assert_eq!(
380 cmd.ids,
381 vec![
382 Id::ShortIdOrUid("id1".to_string()),
383 Id::ShortIdOrUid("id2".to_string())
384 ]
385 );
386 }
387 _ => panic!("Expected TodoUndo command"),
388 }
389 }
390
391 #[test]
392 fn test_parse_todo_done() {
393 let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
394 match cli.command {
395 Commands::TodoDone(cmd) => {
396 assert_eq!(
397 cmd.ids,
398 vec![
399 Id::ShortIdOrUid("id1".to_string()),
400 Id::ShortIdOrUid("id2".to_string())
401 ]
402 );
403 }
404 _ => panic!("Expected TodoDone command"),
405 }
406 }
407
408 #[test]
409 fn test_parse_todo_cancel() {
410 let cli = Cli::try_parse_from(vec!["test", "todo", "cancel", "id1", "id2"]).unwrap();
411 match cli.command {
412 Commands::TodoCancel(cmd) => {
413 assert_eq!(
414 cmd.ids,
415 vec![
416 Id::ShortIdOrUid("id1".to_string()),
417 Id::ShortIdOrUid("id2".to_string())
418 ]
419 );
420 }
421 _ => panic!("Expected TodoDone command"),
422 }
423 }
424
425 #[test]
426 fn test_parse_todo_list() {
427 let args = vec!["test", "todo", "list", "--output-format", "json"];
428 let cli = Cli::try_parse_from(args).unwrap();
429 match cli.command {
430 Commands::TodoList(cmd) => {
431 assert_eq!(cmd.output_format, ArgOutputFormat::Json);
432 }
433 _ => panic!("Expected TodoList command"),
434 }
435 }
436
437 #[test]
438 fn test_parse_done() {
439 let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
440 match cli.command {
441 Commands::TodoDone(cmd) => {
442 assert_eq!(
443 cmd.ids,
444 vec![
445 Id::ShortIdOrUid("id1".to_string()),
446 Id::ShortIdOrUid("id2".to_string())
447 ]
448 );
449 }
450 _ => panic!("Expected TodoDone command"),
451 }
452 }
453
454 #[test]
455 fn test_parse_generate_completions() {
456 let args = vec!["test", "generate-completion", "zsh"];
457 let cli = Cli::try_parse_from(args).unwrap();
458 match cli.command {
459 Commands::GenerateCompletion(cmd) => {
460 assert_eq!(cmd.shell, Shell::Zsh);
461 }
462 _ => panic!("Expected GenerateCompletion command"),
463 }
464 }
465}