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::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
19/// Run the AIM command-line interface.
20pub 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/// 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(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)) // TODO: remove in v0.4.0
94            .subcommand(CmdGenerateCompletion::command())
95    }
96
97    /// Parse the command-line arguments
98    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    /// Parse the specified arguments
105    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    /// Create a CLI instance from the `ArgMatches`
116    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    /// Run the command
148    pub async fn run(self) -> Result<(), Box<dyn Error>> {
149        self.command.run(self.config).await
150    }
151}
152
153/// The commands available in the CLI
154#[derive(Debug, Clone)]
155pub enum Commands {
156    /// Show the dashboard
157    Dashboard(CmdDashboard),
158
159    /// New a event or todo
160    New(CmdNew),
161
162    /// Edit a event or todo
163    Edit(CmdEdit),
164
165    /// List events
166    EventList(CmdEventList),
167
168    /// Add a new todo
169    TodoNew(CmdTodoNew),
170
171    /// Edit a todo
172    TodoEdit(CmdTodoEdit),
173
174    /// Mark a todo as done
175    TodoDone(CmdTodoDone),
176
177    /// Mark a todo as undone
178    TodoUndo(CmdTodoUndo),
179
180    /// List todos
181    TodoList(CmdTodoList),
182
183    /// Mark a todo as undone
184    Undo(CmdTodoUndo),
185
186    /// Generate shell completion
187    GenerateCompletion(CmdGenerateCompletion),
188}
189
190impl Commands {
191    /// Run the command with the given configuration
192    #[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}