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::CmdEventList;
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 match Cli::parse() {
23 Ok(cli) => {
24 if let Err(e) = cli.run().await {
25 println!("{} {}", "Error:".red(), e);
26 }
27 }
28 Err(e) => println!("{} {}", "Error:".red(), e),
29 };
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(CmdEventList::command()),
79 )
80 .subcommand(
81 Command::new("todo")
82 .alias("t")
83 .about("Manage your todo list")
84 .arg_required_else_help(true)
85 .subcommand_required(true)
86 .subcommand(CmdTodoNew::command())
87 .subcommand(CmdTodoEdit::command())
88 .subcommand(CmdTodoDone::command())
89 .subcommand(CmdTodoUndo::command())
90 .subcommand(CmdTodoList::command()),
91 )
92 .subcommand(CmdTodoDone::command())
93 .subcommand(CmdTodoUndo::command().hide(true)) .subcommand(CmdGenerateCompletion::command())
95 }
96
97 pub fn parse() -> Result<Self, Box<dyn Error>> {
99 let commands = Self::command();
100 let matches = commands.get_matches();
101 Self::from(matches)
102 }
103
104 pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
106 where
107 I: IntoIterator<Item = T>,
108 T: Into<OsString> + Clone,
109 {
110 let commands = Self::command();
111 let matches = commands.try_get_matches_from(args)?;
112 Self::from(matches)
113 }
114
115 pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
117 use Commands::*;
118 let command = match matches.subcommand() {
119 Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
120 Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
121 Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
122 Some(("event", matches)) => match matches.subcommand() {
123 Some(("list", matches)) => EventList(CmdEventList::from(matches)),
124 _ => unreachable!(),
125 },
126 Some(("todo", matches)) => match matches.subcommand() {
127 Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)?),
128 Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
129 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
130 Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
131 Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
132 _ => unreachable!(),
133 },
134 Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
135 Some((CmdTodoUndo::NAME, matches)) => Undo(CmdTodoUndo::from(matches)),
136 Some((CmdGenerateCompletion::NAME, matches)) => {
137 GenerateCompletion(CmdGenerateCompletion::from(matches))
138 }
139 None => Dashboard(CmdDashboard),
140 _ => unreachable!(),
141 };
142
143 let config = matches.get_one("config").cloned();
144 Ok(Cli { config, command })
145 }
146
147 pub async fn run(self) -> Result<(), Box<dyn Error>> {
149 self.command.run(self.config).await
150 }
151}
152
153#[derive(Debug, Clone)]
155pub enum Commands {
156 Dashboard(CmdDashboard),
158
159 New(CmdNew),
161
162 Edit(CmdEdit),
164
165 EventList(CmdEventList),
167
168 TodoNew(CmdTodoNew),
170
171 TodoEdit(CmdTodoEdit),
173
174 TodoDone(CmdTodoDone),
176
177 TodoUndo(CmdTodoUndo),
179
180 TodoList(CmdTodoList),
182
183 Undo(CmdTodoUndo),
185
186 GenerateCompletion(CmdGenerateCompletion),
188}
189
190impl Commands {
191 #[rustfmt::skip]
193 pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
194 use Commands::*;
195 match self {
196 Dashboard(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
197 New(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
198 Edit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
199 EventList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
200 TodoNew(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
201 TodoEdit(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
202 TodoDone(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
203 TodoUndo(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
204 TodoList(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
205 Undo(a) => {
206 println!(
207 "{} `aim undo` is now `aim todo undo`, the shortcut will be removed in v0.4.0",
208 "Deprecated:".yellow(),
209 );
210 Self::run_with(config, |x| a.run(x).boxed()).await
211 },
212 GenerateCompletion(a) => a.run(),
213 }
214 }
215
216 async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
217 where
218 F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
219 {
220 log::debug!("Parsing configuration...");
221 let (core_config, _config) = parse_config(config).await?;
222 let mut aim = Aim::new(core_config).await?;
223
224 f(&mut aim).await?;
225
226 aim.close().await?;
227 Ok(())
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::{cmd_generate_completion::Shell, parser::ArgOutputFormat};
235 use aimcal_core::Id;
236
237 #[test]
238 fn test_parse_config() {
239 let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
240 assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
241 assert!(matches!(cli.command, Commands::Dashboard(_)));
242 }
243
244 #[test]
245 fn test_parse_default_dashboard() {
246 let cli = Cli::try_parse_from(vec!["test"]).unwrap();
247 assert!(matches!(cli.command, Commands::Dashboard(_)));
248 }
249
250 #[test]
251 fn test_parse_dashboard() {
252 let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
253 assert!(matches!(cli.command, Commands::Dashboard(_)));
254 }
255
256 #[test]
257 fn test_parse_new() {
258 let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
259 assert!(matches!(cli.command, Commands::New(_)));
260 }
261
262 #[test]
263 fn test_parse_add() {
264 let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
265 assert!(matches!(cli.command, Commands::New(_)));
266 }
267
268 #[test]
269 fn test_parse_edit() {
270 let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
271 assert!(matches!(cli.command, Commands::Edit(_)));
272 }
273
274 #[test]
275 fn test_parse_event_list() {
276 let args = vec!["test", "event", "list", "--output-format", "json"];
277 let cli = Cli::try_parse_from(args).unwrap();
278 match cli.command {
279 Commands::EventList(cmd) => {
280 assert_eq!(cmd.output_format, ArgOutputFormat::Json);
281 }
282 _ => panic!("Expected EventList command"),
283 }
284 }
285
286 #[test]
287 fn test_parse_todo_new() {
288 let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
289 assert!(matches!(cli.command, Commands::TodoNew(_)));
290 }
291
292 #[test]
293 fn test_parse_todo_add() {
294 let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
295 assert!(matches!(cli.command, Commands::TodoNew(_)));
296 }
297
298 #[test]
299 fn test_parse_todo_edit() {
300 let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
301 let cli = Cli::try_parse_from(args).unwrap();
302 match cli.command {
303 Commands::TodoEdit(cmd) => {
304 assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
305 assert_eq!(cmd.summary, Some("new summary".to_string()));
306 }
307 _ => panic!("Expected TodoEdit command"),
308 }
309 }
310
311 #[test]
312 fn test_parse_todo_done() {
313 let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
314 match cli.command {
315 Commands::TodoDone(cmd) => {
316 assert_eq!(
317 cmd.ids,
318 vec![
319 Id::ShortIdOrUid("id1".to_string()),
320 Id::ShortIdOrUid("id2".to_string())
321 ]
322 );
323 }
324 _ => panic!("Expected TodoDone command"),
325 }
326 }
327
328 #[test]
329 fn test_parse_todo_undo() {
330 let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1"]).unwrap();
331 match cli.command {
332 Commands::TodoUndo(cmd) => {
333 assert_eq!(cmd.ids, vec![Id::ShortIdOrUid("id1".to_string())]);
334 }
335 _ => panic!("Expected TodoUndo command"),
336 }
337 }
338
339 #[test]
340 fn test_parse_todo_list() {
341 let args = vec!["test", "todo", "list", "--output-format", "json"];
342 let cli = Cli::try_parse_from(args).unwrap();
343 match cli.command {
344 Commands::TodoList(cmd) => {
345 assert_eq!(cmd.output_format, ArgOutputFormat::Json);
346 }
347 _ => panic!("Expected TodoList command"),
348 }
349 }
350
351 #[test]
352 fn test_parse_done() {
353 let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
354 match cli.command {
355 Commands::TodoDone(cmd) => {
356 assert_eq!(
357 cmd.ids,
358 vec![
359 Id::ShortIdOrUid("id1".to_string()),
360 Id::ShortIdOrUid("id2".to_string())
361 ]
362 );
363 }
364 _ => panic!("Expected TodoDone command"),
365 }
366 }
367
368 #[test]
369 fn test_parse_undo() {
370 let cli = Cli::try_parse_from(vec!["test", "undo", "id1"]).unwrap();
371 match cli.command {
372 Commands::Undo(cmd) => {
373 assert_eq!(cmd.ids, vec![Id::ShortIdOrUid("id1".to_string())]);
374 }
375 _ => panic!("Expected Undo command"),
376 }
377 }
378
379 #[test]
380 fn test_parse_generate_completions() {
381 let args = vec!["test", "generate-completion", "zsh"];
382 let cli = Cli::try_parse_from(args).unwrap();
383 match cli.command {
384 Commands::GenerateCompletion(cmd) => {
385 assert_eq!(cmd.shell, Shell::Zsh);
386 }
387 _ => panic!("Expected GenerateCompletion command"),
388 }
389 }
390}