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, CmdTodoDelay, 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(CmdTodoDelay::command())
100                    .subcommand(CmdTodoList::command()),
101            )
102            .subcommand(CmdTodoDone::command())
103            .subcommand(CmdGenerateCompletion::command())
104    }
105
106    /// Parse the command-line arguments
107    pub fn parse() -> Result<Self, Box<dyn Error>> {
108        let commands = Self::command();
109        let matches = commands.get_matches();
110        Self::from(matches)
111    }
112
113    /// Parse the specified arguments
114    pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
115    where
116        I: IntoIterator<Item = T>,
117        T: Into<OsString> + Clone,
118    {
119        let commands = Self::command();
120        let matches = commands.try_get_matches_from(args)?;
121        Self::from(matches)
122    }
123
124    /// Create a CLI instance from the `ArgMatches`
125    pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
126        use Commands::*;
127        let command = match matches.subcommand() {
128            Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
129            Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
130            Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
131            Some(("event", matches)) => match matches.subcommand() {
132                Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)?),
133                Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
134                Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
135                _ => unreachable!(),
136            },
137            Some(("todo", matches)) => match matches.subcommand() {
138                Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)?),
139                Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
140                Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
141                Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
142                Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
143                Some((CmdTodoDelay::NAME, matches)) => TodoDelay(CmdTodoDelay::from(matches)),
144                Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
145                _ => unreachable!(),
146            },
147            Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
148            Some((CmdGenerateCompletion::NAME, matches)) => {
149                GenerateCompletion(CmdGenerateCompletion::from(matches))
150            }
151            None => Dashboard(CmdDashboard),
152            _ => unreachable!(),
153        };
154
155        let config = matches.get_one("config").cloned();
156        Ok(Cli { config, command })
157    }
158
159    /// Run the command
160    pub async fn run(self) -> Result<(), Box<dyn Error>> {
161        self.command.run(self.config).await
162    }
163}
164
165/// The commands available in the CLI
166#[derive(Debug, Clone)]
167pub enum Commands {
168    /// Show the dashboard
169    Dashboard(CmdDashboard),
170
171    /// New a event or todo
172    New(CmdNew),
173
174    /// Edit a event or todo
175    Edit(CmdEdit),
176
177    /// Add a new event
178    EventNew(CmdEventNew),
179
180    /// Edit a event
181    EventEdit(CmdEventEdit),
182
183    /// List events
184    EventList(CmdEventList),
185
186    /// Add a new todo
187    TodoNew(CmdTodoNew),
188
189    /// Edit a todo
190    TodoEdit(CmdTodoEdit),
191
192    /// Mark a todo as needs-action
193    TodoUndo(CmdTodoUndo),
194
195    /// Mark a todo as completed
196    TodoDone(CmdTodoDone),
197
198    /// Mark a todo as cancelled
199    TodoCancel(CmdTodoCancel),
200
201    /// Delay a todo
202    TodoDelay(CmdTodoDelay),
203
204    /// List todos
205    TodoList(CmdTodoList),
206
207    /// Generate shell completion
208    GenerateCompletion(CmdGenerateCompletion),
209}
210
211impl Commands {
212    /// Run the command with the given configuration
213    #[rustfmt::skip]
214    #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
215    pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
216        use Commands::*;
217        tracing::info!(?self, "running command");
218        match self {
219            Dashboard(a)  => Self::run_with(config, |x| a.run(x).boxed()).await,
220            New(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
221            Edit(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
222            EventNew(a)   => Self::run_with(config, |x| a.run(x).boxed()).await,
223            EventEdit(a)  => Self::run_with(config, |x| a.run(x).boxed()).await,
224            EventList(a)  => Self::run_with(config, |x| a.run(x).boxed()).await,
225            TodoNew(a)    => Self::run_with(config, |x| a.run(x).boxed()).await,
226            TodoEdit(a)   => Self::run_with(config, |x| a.run(x).boxed()).await,
227            TodoUndo(a)   => Self::run_with(config, |x| a.run(x).boxed()).await,
228            TodoDone(a)   => Self::run_with(config, |x| a.run(x).boxed()).await,
229            TodoCancel(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
230            TodoDelay(a)  => Self::run_with(config, |x| a.run(x).boxed()).await,
231            TodoList(a)   => Self::run_with(config, |x| a.run(x).boxed()).await,
232            GenerateCompletion(a) => a.run(),
233        }
234    }
235
236    async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
237    where
238        F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
239    {
240        tracing::debug!("parsing configuration...");
241        let (core_config, _config) = parse_config(config).await?;
242
243        tracing::debug!("instantiating...");
244        let mut aim = Aim::new(core_config).await?;
245
246        tracing::debug!("running command...");
247        f(&mut aim).await?;
248
249        tracing::debug!("closing...");
250        aim.close().await?;
251        Ok(())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::{cmd_generate_completion::Shell, util::ArgOutputFormat};
259    use aimcal_core::Id;
260
261    #[test]
262    fn test_parse_config() {
263        let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
264        assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
265        assert!(matches!(cli.command, Commands::Dashboard(_)));
266    }
267
268    #[test]
269    fn test_parse_default_dashboard() {
270        let cli = Cli::try_parse_from(vec!["test"]).unwrap();
271        assert!(matches!(cli.command, Commands::Dashboard(_)));
272    }
273
274    #[test]
275    fn test_parse_dashboard() {
276        let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
277        assert!(matches!(cli.command, Commands::Dashboard(_)));
278    }
279
280    #[test]
281    fn test_parse_new() {
282        let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
283        assert!(matches!(cli.command, Commands::New(_)));
284    }
285
286    #[test]
287    fn test_parse_add() {
288        let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
289        assert!(matches!(cli.command, Commands::New(_)));
290    }
291
292    #[test]
293    fn test_parse_edit() {
294        let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
295        assert!(matches!(cli.command, Commands::Edit(_)));
296    }
297
298    #[test]
299    fn test_parse_event_new() {
300        let cli = Cli::try_parse_from(vec![
301            "test",
302            "event",
303            "new",
304            "a new event",
305            "--start",
306            "2025-01-01 10:00",
307            "--end",
308            "2025-01-01 12:00",
309        ])
310        .unwrap();
311        assert!(matches!(cli.command, Commands::EventNew(_)));
312    }
313
314    #[test]
315    fn test_parse_event_add() {
316        let cli = Cli::try_parse_from(vec![
317            "test",
318            "event",
319            "add",
320            "a new event",
321            "--start",
322            "2025-01-01 10:00",
323            "--end",
324            "2025-01-01 12:00",
325        ])
326        .unwrap();
327        assert!(matches!(cli.command, Commands::EventNew(_)));
328    }
329
330    #[test]
331    fn test_parse_event_edit() {
332        let args = vec!["test", "event", "edit", "some_id", "-s", "new summary"];
333        let cli = Cli::try_parse_from(args).unwrap();
334        match cli.command {
335            Commands::EventEdit(cmd) => {
336                assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
337                assert_eq!(cmd.summary, Some("new summary".to_string()));
338            }
339            _ => panic!("Expected EventEdit command"),
340        }
341    }
342
343    #[test]
344    fn test_parse_event_list() {
345        let args = vec!["test", "event", "list", "--output-format", "json"];
346        let cli = Cli::try_parse_from(args).unwrap();
347        match cli.command {
348            Commands::EventList(cmd) => {
349                assert_eq!(cmd.output_format, ArgOutputFormat::Json);
350            }
351            _ => panic!("Expected EventList command"),
352        }
353    }
354
355    #[test]
356    fn test_parse_todo_new() {
357        let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
358        assert!(matches!(cli.command, Commands::TodoNew(_)));
359    }
360
361    #[test]
362    fn test_parse_todo_add() {
363        let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
364        assert!(matches!(cli.command, Commands::TodoNew(_)));
365    }
366
367    #[test]
368    fn test_parse_todo_edit() {
369        let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
370        let cli = Cli::try_parse_from(args).unwrap();
371        match cli.command {
372            Commands::TodoEdit(cmd) => {
373                assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
374                assert_eq!(cmd.summary, Some("new summary".to_string()));
375            }
376            _ => panic!("Expected TodoEdit command"),
377        }
378    }
379
380    #[test]
381    fn test_parse_todo_undo() {
382        let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1", "id2"]).unwrap();
383        match cli.command {
384            Commands::TodoUndo(cmd) => {
385                assert_eq!(
386                    cmd.ids,
387                    vec![
388                        Id::ShortIdOrUid("id1".to_string()),
389                        Id::ShortIdOrUid("id2".to_string())
390                    ]
391                );
392            }
393            _ => panic!("Expected TodoUndo command"),
394        }
395    }
396
397    #[test]
398    fn test_parse_todo_done() {
399        let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
400        match cli.command {
401            Commands::TodoDone(cmd) => {
402                assert_eq!(
403                    cmd.ids,
404                    vec![
405                        Id::ShortIdOrUid("id1".to_string()),
406                        Id::ShortIdOrUid("id2".to_string())
407                    ]
408                );
409            }
410            _ => panic!("Expected TodoDone command"),
411        }
412    }
413
414    #[test]
415    fn test_parse_todo_cancel() {
416        let cli = Cli::try_parse_from(vec!["test", "todo", "cancel", "id1", "id2"]).unwrap();
417        match cli.command {
418            Commands::TodoCancel(cmd) => {
419                assert_eq!(
420                    cmd.ids,
421                    vec![
422                        Id::ShortIdOrUid("id1".to_string()),
423                        Id::ShortIdOrUid("id2".to_string())
424                    ]
425                );
426            }
427            _ => panic!("Expected TodoDone command"),
428        }
429    }
430
431    #[test]
432    fn test_parse_todo_list() {
433        let args = vec!["test", "todo", "list", "--output-format", "json"];
434        let cli = Cli::try_parse_from(args).unwrap();
435        match cli.command {
436            Commands::TodoList(cmd) => {
437                assert_eq!(cmd.output_format, ArgOutputFormat::Json);
438            }
439            _ => panic!("Expected TodoList command"),
440        }
441    }
442
443    #[test]
444    fn test_parse_done() {
445        let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
446        match cli.command {
447            Commands::TodoDone(cmd) => {
448                assert_eq!(
449                    cmd.ids,
450                    vec![
451                        Id::ShortIdOrUid("id1".to_string()),
452                        Id::ShortIdOrUid("id2".to_string())
453                    ]
454                );
455            }
456            _ => panic!("Expected TodoDone command"),
457        }
458    }
459
460    #[test]
461    fn test_parse_generate_completions() {
462        let args = vec!["test", "generate-completion", "zsh"];
463        let cli = Cli::try_parse_from(args).unwrap();
464        match cli.command {
465            Commands::GenerateCompletion(cmd) => {
466                assert_eq!(cmd.shell, Shell::Zsh);
467            }
468            _ => panic!("Expected GenerateCompletion command"),
469        }
470    }
471}