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};
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
22/// Run the AIM command-line interface.
23pub 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/// Command-line interface
40#[derive(Debug)]
41pub struct Cli {
42    /// Path to the configuration file
43    pub config: Option<PathBuf>,
44
45    /// The command to execute
46    pub command: Commands,
47}
48
49impl Cli {
50    /// Create the command-line interface
51    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) // allow default to dashboard
64            .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    /// Parse the command-line arguments
106    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    /// Parse the specified arguments
113    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    /// Create a CLI instance from the `ArgMatches`
124    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    /// Run the command
158    pub async fn run(self) -> Result<(), Box<dyn Error>> {
159        self.command.run(self.config).await
160    }
161}
162
163/// The commands available in the CLI
164#[derive(Debug, Clone)]
165pub enum Commands {
166    /// Show the dashboard
167    Dashboard(CmdDashboard),
168
169    /// New a event or todo
170    New(CmdNew),
171
172    /// Edit a event or todo
173    Edit(CmdEdit),
174
175    /// Add a new event
176    EventNew(CmdEventNew),
177
178    /// Edit a event
179    EventEdit(CmdEventEdit),
180
181    /// List events
182    EventList(CmdEventList),
183
184    /// Add a new todo
185    TodoNew(CmdTodoNew),
186
187    /// Edit a todo
188    TodoEdit(CmdTodoEdit),
189
190    /// Mark a todo as needs-action
191    TodoUndo(CmdTodoUndo),
192
193    /// Mark a todo as completed
194    TodoDone(CmdTodoDone),
195
196    /// Mark a todo as cancelled
197    TodoCancel(CmdTodoCancel),
198
199    /// List todos
200    TodoList(CmdTodoList),
201
202    /// Generate shell completion
203    GenerateCompletion(CmdGenerateCompletion),
204}
205
206impl Commands {
207    /// Run the command with the given configuration
208    #[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}