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