aimcal_cli/
cli.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use 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
19/// Run the AIM command-line interface.
20pub 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/// Command-line interface
34#[derive(Debug)]
35pub struct Cli {
36    /// Path to the configuration file
37    pub config: Option<PathBuf>,
38
39    /// The command to execute
40    pub command: Commands,
41}
42
43impl Cli {
44    /// Create the command-line interface
45    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) // allow default to dashboard
58            .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)) // TODO: remove in v0.4.0
96            .subcommand(CmdGenerateCompletion::command())
97    }
98
99    /// Parse the command-line arguments
100    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    /// Parse the specified arguments
107    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    /// Create a CLI instance from the `ArgMatches`
118    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    /// Run the command
152    pub async fn run(self) -> Result<(), Box<dyn Error>> {
153        self.command.run(self.config).await
154    }
155}
156
157/// The commands available in the CLI
158#[derive(Debug, Clone)]
159pub enum Commands {
160    /// Show the dashboard
161    Dashboard(CmdDashboard),
162
163    /// New a event or todo
164    New(CmdNew),
165
166    /// Edit a event or todo
167    Edit(CmdEdit),
168
169    /// Add a new event
170    EventNew(CmdEventNew),
171
172    /// Edit a event
173    EventEdit(CmdEventEdit),
174
175    /// List events
176    EventList(CmdEventList),
177
178    /// Add a new todo
179    TodoNew(CmdTodoNew),
180
181    /// Edit a todo
182    TodoEdit(CmdTodoEdit),
183
184    /// Mark a todo as done
185    TodoDone(CmdTodoDone),
186
187    /// Mark a todo as undone
188    TodoUndo(CmdTodoUndo),
189
190    /// List todos
191    TodoList(CmdTodoList),
192
193    /// Mark a todo as undone
194    Undo(CmdTodoUndo),
195
196    /// Generate shell completion
197    GenerateCompletion(CmdGenerateCompletion),
198}
199
200impl Commands {
201    /// Run the command with the given configuration
202    #[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}