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_event::{
14    CmdEventDelay, CmdEventEdit, CmdEventList, CmdEventNew, CmdEventReschedule,
15};
16use crate::cmd_generate_completion::CmdGenerateCompletion;
17use crate::cmd_todo::{
18    CmdTodoCancel, CmdTodoDelay, CmdTodoDone, CmdTodoEdit, CmdTodoList, CmdTodoNew,
19    CmdTodoReschedule, CmdTodoUndo,
20};
21use crate::cmd_toplevel::{CmdDashboard, CmdDelay, CmdFlush, CmdReschedule};
22use crate::cmd_tui::{CmdEdit, CmdNew};
23use crate::config::parse_config;
24
25/// Run the AIM command-line interface.
26pub async fn run() -> Result<(), Box<dyn Error>> {
27    tracing_subscriber::fmt()
28        .with_env_filter(EnvFilter::from_default_env())
29        .init();
30
31    let err = match Cli::parse() {
32        Ok(cli) => match cli.run().await {
33            Ok(()) => return Ok(()),
34            Err(e) => e,
35        },
36        Err(e) => e,
37    };
38    println!("{} {}", "Error:".red(), err);
39    Ok(())
40}
41
42/// Command-line interface
43#[derive(Debug)]
44pub struct Cli {
45    /// Path to the configuration file
46    pub config: Option<PathBuf>,
47
48    /// The command to execute
49    pub command: Commands,
50}
51
52impl Cli {
53    /// Create the command-line interface
54    pub fn command() -> Command {
55        const STYLES: styling::Styles = styling::Styles::styled()
56            .header(styling::AnsiColor::Green.on_default().bold())
57            .usage(styling::AnsiColor::Green.on_default().bold())
58            .literal(styling::AnsiColor::Blue.on_default().bold())
59            .placeholder(styling::AnsiColor::Cyan.on_default());
60
61        Command::new(APP_NAME)
62            .about("Analyze. Interact. Manage Your Time, with calendar support.")
63            .author("Zexin Yuan <aim@yzx9.xyz>")
64            .version(crate_version!())
65            .styles(STYLES)
66            .subcommand_required(false) // allow default to dashboard
67            .arg_required_else_help(false)
68            .arg(
69                arg!(-c --config [CONFIG] "Path to the configuration file")
70                    .long_help(
71                        "\
72Path to the configuration file. Defaults to $XDG_CONFIG_HOME/aim/config.toml on Linux and MacOS, \
73%LOCALAPPDATA%/aim/config.toml on Windows.",
74                    )
75                    .value_parser(value_parser!(PathBuf))
76                    .value_hint(ValueHint::FilePath),
77            )
78            .subcommand(CmdDashboard::command())
79            .subcommand(CmdNew::command())
80            .subcommand(CmdEdit::command())
81            .subcommand(CmdDelay::command())
82            .subcommand(CmdReschedule::command())
83            .subcommand(
84                Command::new("event")
85                    .alias("e")
86                    .about("Manage your event list")
87                    .arg_required_else_help(true)
88                    .subcommand_required(true)
89                    .subcommand(CmdEventNew::command())
90                    .subcommand(CmdEventEdit::command())
91                    .subcommand(CmdEventDelay::command())
92                    .subcommand(CmdEventReschedule::command())
93                    .subcommand(CmdEventList::command()),
94            )
95            .subcommand(
96                Command::new("todo")
97                    .alias("t")
98                    .about("Manage your todo list")
99                    .arg_required_else_help(true)
100                    .subcommand_required(true)
101                    .subcommand(CmdTodoNew::command())
102                    .subcommand(CmdTodoEdit::command())
103                    .subcommand(CmdTodoDone::command())
104                    .subcommand(CmdTodoUndo::command())
105                    .subcommand(CmdTodoCancel::command())
106                    .subcommand(CmdTodoDelay::command())
107                    .subcommand(CmdTodoReschedule::command())
108                    .subcommand(CmdTodoList::command()),
109            )
110            .subcommand(CmdTodoDone::command())
111            .subcommand(CmdFlush::command())
112            .subcommand(CmdGenerateCompletion::command())
113    }
114
115    /// Parse the command-line arguments
116    pub fn parse() -> Result<Self, Box<dyn Error>> {
117        let commands = Self::command();
118        let matches = commands.get_matches();
119        Self::from(matches)
120    }
121
122    /// Parse the specified arguments
123    pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
124    where
125        I: IntoIterator<Item = T>,
126        T: Into<OsString> + Clone,
127    {
128        let commands = Self::command();
129        let matches = commands.try_get_matches_from(args)?;
130        Self::from(matches)
131    }
132
133    /// Create a CLI instance from the `ArgMatches`
134    pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
135        use Commands::*;
136        let command = match matches.subcommand() {
137            Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
138            Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
139            Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
140            Some((CmdDelay::NAME, matches)) => Delay(CmdDelay::from(matches)),
141            Some((CmdReschedule::NAME, matches)) => Reschedule(CmdReschedule::from(matches)),
142            Some((CmdFlush::NAME, matches)) => Flush(CmdFlush::from(matches)),
143            Some(("event", matches)) => match matches.subcommand() {
144                Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)),
145                Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
146                Some((CmdEventDelay::NAME, matches)) => EventDelay(CmdEventDelay::from(matches)),
147                Some((CmdEventReschedule::NAME, matches)) => {
148                    EventReschedule(CmdEventReschedule::from(matches))
149                }
150                Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
151                _ => unreachable!(),
152            },
153            Some(("todo", matches)) => match matches.subcommand() {
154                Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)),
155                Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
156                Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
157                Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
158                Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
159                Some((CmdTodoDelay::NAME, matches)) => TodoDelay(CmdTodoDelay::from(matches)),
160                Some((CmdTodoReschedule::NAME, matches)) => {
161                    TodoReschedule(CmdTodoReschedule::from(matches))
162                }
163                Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
164                _ => unreachable!(),
165            },
166            Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
167            Some((CmdGenerateCompletion::NAME, matches)) => {
168                GenerateCompletion(CmdGenerateCompletion::from(matches))
169            }
170            None => Dashboard(CmdDashboard),
171            _ => unreachable!(),
172        };
173
174        let config = matches.get_one("config").cloned();
175        Ok(Cli { config, command })
176    }
177
178    /// Run the command
179    pub async fn run(self) -> Result<(), Box<dyn Error>> {
180        self.command.run(self.config).await
181    }
182}
183
184/// The commands available in the CLI
185#[derive(Debug, Clone)]
186pub enum Commands {
187    /// Show the dashboard
188    Dashboard(CmdDashboard),
189
190    /// New a event or todo
191    New(CmdNew),
192
193    /// Edit a event or todo
194    Edit(CmdEdit),
195
196    /// Delay an event or todo based on original time
197    Delay(CmdDelay),
198
199    /// Reschedule an event or todo based on current time
200    Reschedule(CmdReschedule),
201
202    /// Flush the short IDs
203    Flush(CmdFlush),
204
205    /// Add a new event
206    EventNew(CmdEventNew),
207
208    /// Edit an event
209    EventEdit(CmdEventEdit),
210
211    /// Delay an event based on original start
212    EventDelay(CmdEventDelay),
213
214    /// Reschedule an event based on current time
215    EventReschedule(CmdEventReschedule),
216
217    /// List events
218    EventList(CmdEventList),
219
220    /// Add a new todo
221    TodoNew(CmdTodoNew),
222
223    /// Edit a todo
224    TodoEdit(CmdTodoEdit),
225
226    /// Mark a todo as needs-action
227    TodoUndo(CmdTodoUndo),
228
229    /// Mark a todo as completed
230    TodoDone(CmdTodoDone),
231
232    /// Mark a todo as cancelled
233    TodoCancel(CmdTodoCancel),
234
235    /// Delay a todo based on original due
236    TodoDelay(CmdTodoDelay),
237
238    /// Reschedule a todo based on current time
239    TodoReschedule(CmdTodoReschedule),
240
241    /// List todos
242    TodoList(CmdTodoList),
243
244    /// Generate shell completion
245    GenerateCompletion(CmdGenerateCompletion),
246}
247
248impl Commands {
249    /// Run the command with the given configuration
250    #[rustfmt::skip]
251    #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
252    pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
253        use Commands::*;
254        tracing::info!(?self, "running command");
255        match self {
256            Dashboard(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
257            New(a)             => Self::run_with(config, |x| a.run(x).boxed()).await,
258            Edit(a)            => Self::run_with(config, |x| a.run(x).boxed()).await,
259            Delay(a)           => Self::run_with(config, |x| a.run(x).boxed()).await,
260            Reschedule(a)      => Self::run_with(config, |x| a.run(x).boxed()).await,
261            Flush(a)           => Self::run_with(config, |x| a.run(x).boxed()).await,
262            EventNew(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
263            EventEdit(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
264            EventDelay(a)      => Self::run_with(config, |x| a.run(x).boxed()).await,
265            EventReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
266            EventList(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
267            TodoNew(a)         => Self::run_with(config, |x| a.run(x).boxed()).await,
268            TodoEdit(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
269            TodoUndo(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
270            TodoDone(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
271            TodoCancel(a)      => Self::run_with(config, |x| a.run(x).boxed()).await,
272            TodoDelay(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
273            TodoReschedule(a)  => Self::run_with(config, |x| a.run(x).boxed()).await,
274            TodoList(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
275            GenerateCompletion(a) => a.run(),
276        }
277    }
278
279    async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
280    where
281        F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
282    {
283        tracing::debug!("parsing configuration...");
284        let (core_config, _config) = parse_config(config).await?;
285
286        tracing::debug!("instantiating...");
287        let mut aim = Aim::new(core_config).await?;
288
289        tracing::debug!("running command...");
290        f(&mut aim).await?;
291
292        tracing::debug!("closing...");
293        aim.close().await?;
294        Ok(())
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use aimcal_core::Id;
301
302    use crate::{cmd_generate_completion::Shell, util::OutputFormat};
303
304    use super::*;
305
306    #[test]
307    fn test_parse_config() {
308        let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
309        assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
310        assert!(matches!(cli.command, Commands::Dashboard(_)));
311    }
312
313    #[test]
314    fn test_parse_default_dashboard() {
315        let cli = Cli::try_parse_from(vec!["test"]).unwrap();
316        assert!(matches!(cli.command, Commands::Dashboard(_)));
317    }
318
319    #[test]
320    fn test_parse_dashboard() {
321        let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
322        assert!(matches!(cli.command, Commands::Dashboard(_)));
323    }
324
325    #[test]
326    fn test_parse_new() {
327        let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
328        assert!(matches!(cli.command, Commands::New(_)));
329    }
330
331    #[test]
332    fn test_parse_add() {
333        let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
334        assert!(matches!(cli.command, Commands::New(_)));
335    }
336
337    #[test]
338    fn test_parse_edit() {
339        let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
340        assert!(matches!(cli.command, Commands::Edit(_)));
341    }
342
343    #[test]
344    fn test_parse_flush() {
345        let cli = Cli::try_parse_from(vec!["test", "flush"]).unwrap();
346        assert!(matches!(cli.command, Commands::Flush(_)));
347    }
348
349    #[test]
350    fn test_parse_event_new() {
351        let cli = Cli::try_parse_from(vec![
352            "test",
353            "event",
354            "new",
355            "a new event",
356            "--start",
357            "2025-01-01 10:00",
358            "--end",
359            "2025-01-01 12:00",
360        ])
361        .unwrap();
362        assert!(matches!(cli.command, Commands::EventNew(_)));
363    }
364
365    #[test]
366    fn test_parse_event_add() {
367        let cli = Cli::try_parse_from(vec![
368            "test",
369            "event",
370            "add",
371            "a new event",
372            "--start",
373            "2025-01-01 10:00",
374            "--end",
375            "2025-01-01 12:00",
376        ])
377        .unwrap();
378        assert!(matches!(cli.command, Commands::EventNew(_)));
379    }
380
381    #[test]
382    fn test_parse_event_edit() {
383        let args = vec!["test", "event", "edit", "some_id", "-s", "new summary"];
384        let cli = Cli::try_parse_from(args).unwrap();
385        match cli.command {
386            Commands::EventEdit(cmd) => {
387                assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
388                assert_eq!(cmd.summary, Some("new summary".to_string()));
389            }
390            _ => panic!("Expected EventEdit command"),
391        }
392    }
393
394    #[test]
395    fn test_parse_event_delay() {
396        let cli = Cli::try_parse_from(vec!["test", "event", "delay", "id1", "id2"]).unwrap();
397        match cli.command {
398            Commands::EventDelay(cmd) => {
399                let expected_ids = vec![
400                    Id::ShortIdOrUid("id1".to_string()),
401                    Id::ShortIdOrUid("id2".to_string()),
402                ];
403                assert_eq!(cmd.ids, expected_ids);
404            }
405            _ => panic!("Expected EventDelay command"),
406        }
407    }
408
409    #[test]
410    fn test_parse_event_reschedule() {
411        let cli = Cli::try_parse_from(vec!["test", "event", "reschedule", "id1", "id2"]).unwrap();
412        match cli.command {
413            Commands::EventReschedule(cmd) => {
414                let expected_ids = vec![
415                    Id::ShortIdOrUid("id1".to_string()),
416                    Id::ShortIdOrUid("id2".to_string()),
417                ];
418                assert_eq!(cmd.ids, expected_ids);
419            }
420            _ => panic!("Expected EventReschedule command"),
421        }
422    }
423
424    #[test]
425    fn test_parse_event_list() {
426        let args = vec!["test", "event", "list", "--output-format", "json"];
427        let cli = Cli::try_parse_from(args).unwrap();
428        match cli.command {
429            Commands::EventList(cmd) => {
430                assert_eq!(cmd.output_format, OutputFormat::Json);
431            }
432            _ => panic!("Expected EventList command"),
433        }
434    }
435
436    #[test]
437    fn test_parse_todo_new() {
438        let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
439        assert!(matches!(cli.command, Commands::TodoNew(_)));
440    }
441
442    #[test]
443    fn test_parse_todo_add() {
444        let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
445        assert!(matches!(cli.command, Commands::TodoNew(_)));
446    }
447
448    #[test]
449    fn test_parse_todo_edit() {
450        let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
451        let cli = Cli::try_parse_from(args).unwrap();
452        match cli.command {
453            Commands::TodoEdit(cmd) => {
454                assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
455                assert_eq!(cmd.summary, Some("new summary".to_string()));
456            }
457            _ => panic!("Expected TodoEdit command"),
458        }
459    }
460
461    #[test]
462    fn test_parse_todo_undo() {
463        let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1", "id2"]).unwrap();
464        match cli.command {
465            Commands::TodoUndo(cmd) => {
466                let expected_ids = vec![
467                    Id::ShortIdOrUid("id1".to_string()),
468                    Id::ShortIdOrUid("id2".to_string()),
469                ];
470                assert_eq!(cmd.ids, expected_ids);
471            }
472            _ => panic!("Expected TodoUndo command"),
473        }
474    }
475
476    #[test]
477    fn test_parse_todo_done() {
478        let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
479        match cli.command {
480            Commands::TodoDone(cmd) => {
481                let expected_ids = vec![
482                    Id::ShortIdOrUid("id1".to_string()),
483                    Id::ShortIdOrUid("id2".to_string()),
484                ];
485                assert_eq!(cmd.ids, expected_ids);
486            }
487            _ => panic!("Expected TodoDone command"),
488        }
489    }
490
491    #[test]
492    fn test_parse_todo_cancel() {
493        let cli = Cli::try_parse_from(vec!["test", "todo", "cancel", "id1", "id2"]).unwrap();
494        match cli.command {
495            Commands::TodoCancel(cmd) => {
496                let expected_ids = vec![
497                    Id::ShortIdOrUid("id1".to_string()),
498                    Id::ShortIdOrUid("id2".to_string()),
499                ];
500                assert_eq!(cmd.ids, expected_ids);
501            }
502            _ => panic!("Expected TodoDone command"),
503        }
504    }
505
506    #[test]
507    fn test_parse_todo_delay() {
508        let cli = Cli::try_parse_from(vec!["test", "todo", "delay", "id1", "id2", "id3"]).unwrap();
509        match cli.command {
510            Commands::TodoDelay(cmd) => {
511                let expected_ids = vec![
512                    Id::ShortIdOrUid("id1".to_string()),
513                    Id::ShortIdOrUid("id2".to_string()),
514                    Id::ShortIdOrUid("id3".to_string()),
515                ];
516                assert_eq!(cmd.ids, expected_ids);
517            }
518            _ => panic!("Expected TodoDelay command"),
519        }
520    }
521
522    #[test]
523    fn test_parse_todo_reschedule() {
524        let cli = Cli::try_parse_from(vec!["test", "todo", "reschedule", "id1", "id2"]).unwrap();
525        match cli.command {
526            Commands::TodoReschedule(cmd) => {
527                let expected_ids = vec![
528                    Id::ShortIdOrUid("id1".to_string()),
529                    Id::ShortIdOrUid("id2".to_string()),
530                ];
531                assert_eq!(cmd.ids, expected_ids);
532            }
533            _ => panic!("Expected TodoReschedule command"),
534        }
535    }
536
537    #[test]
538    fn test_parse_todo_list() {
539        let args = vec!["test", "todo", "list", "--output-format", "json"];
540        let cli = Cli::try_parse_from(args).unwrap();
541        match cli.command {
542            Commands::TodoList(cmd) => {
543                assert_eq!(cmd.output_format, OutputFormat::Json);
544            }
545            _ => panic!("Expected TodoList command"),
546        }
547    }
548
549    #[test]
550    fn test_parse_done() {
551        let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
552        match cli.command {
553            Commands::TodoDone(cmd) => {
554                assert_eq!(
555                    cmd.ids,
556                    vec![
557                        Id::ShortIdOrUid("id1".to_string()),
558                        Id::ShortIdOrUid("id2".to_string())
559                    ]
560                );
561            }
562            _ => panic!("Expected TodoDone command"),
563        }
564    }
565
566    #[test]
567    fn test_parse_generate_completions() {
568        let args = vec!["test", "generate-completion", "zsh"];
569        let cli = Cli::try_parse_from(args).unwrap();
570        match cli.command {
571            Commands::GenerateCompletion(cmd) => {
572                assert_eq!(cmd.shell, Shell::Zsh);
573            }
574            _ => panic!("Expected GenerateCompletion command"),
575        }
576    }
577}