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, 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(CmdGenerateCompletion::command())
112    }
113
114    /// Parse the command-line arguments
115    pub fn parse() -> Result<Self, Box<dyn Error>> {
116        let commands = Self::command();
117        let matches = commands.get_matches();
118        Self::from(matches)
119    }
120
121    /// Parse the specified arguments
122    pub fn try_parse_from<I, T>(args: I) -> Result<Self, Box<dyn Error>>
123    where
124        I: IntoIterator<Item = T>,
125        T: Into<OsString> + Clone,
126    {
127        let commands = Self::command();
128        let matches = commands.try_get_matches_from(args)?;
129        Self::from(matches)
130    }
131
132    /// Create a CLI instance from the `ArgMatches`
133    pub fn from(matches: ArgMatches) -> Result<Self, Box<dyn Error>> {
134        use Commands::*;
135        let command = match matches.subcommand() {
136            Some((CmdDashboard::NAME, matches)) => Dashboard(CmdDashboard::from(matches)),
137            Some((CmdNew::NAME, matches)) => New(CmdNew::from(matches)),
138            Some((CmdEdit::NAME, matches)) => Edit(CmdEdit::from(matches)),
139            Some((CmdDelay::NAME, matches)) => Delay(CmdDelay::from(matches)),
140            Some((CmdReschedule::NAME, matches)) => Reschedule(CmdReschedule::from(matches)),
141            Some(("event", matches)) => match matches.subcommand() {
142                Some((CmdEventNew::NAME, matches)) => EventNew(CmdEventNew::from(matches)),
143                Some((CmdEventEdit::NAME, matches)) => EventEdit(CmdEventEdit::from(matches)),
144                Some((CmdEventDelay::NAME, matches)) => EventDelay(CmdEventDelay::from(matches)),
145                Some((CmdEventReschedule::NAME, matches)) => {
146                    EventReschedule(CmdEventReschedule::from(matches))
147                }
148                Some((CmdEventList::NAME, matches)) => EventList(CmdEventList::from(matches)),
149                _ => unreachable!(),
150            },
151            Some(("todo", matches)) => match matches.subcommand() {
152                Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::from(matches)),
153                Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::from(matches)),
154                Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::from(matches)),
155                Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
156                Some((CmdTodoCancel::NAME, matches)) => TodoCancel(CmdTodoCancel::from(matches)),
157                Some((CmdTodoDelay::NAME, matches)) => TodoDelay(CmdTodoDelay::from(matches)),
158                Some((CmdTodoReschedule::NAME, matches)) => {
159                    TodoReschedule(CmdTodoReschedule::from(matches))
160                }
161                Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::from(matches)),
162                _ => unreachable!(),
163            },
164            Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::from(matches)),
165            Some((CmdGenerateCompletion::NAME, matches)) => {
166                GenerateCompletion(CmdGenerateCompletion::from(matches))
167            }
168            None => Dashboard(CmdDashboard),
169            _ => unreachable!(),
170        };
171
172        let config = matches.get_one("config").cloned();
173        Ok(Cli { config, command })
174    }
175
176    /// Run the command
177    pub async fn run(self) -> Result<(), Box<dyn Error>> {
178        self.command.run(self.config).await
179    }
180}
181
182/// The commands available in the CLI
183#[derive(Debug, Clone)]
184pub enum Commands {
185    /// Show the dashboard
186    Dashboard(CmdDashboard),
187
188    /// New a event or todo
189    New(CmdNew),
190
191    /// Edit a event or todo
192    Edit(CmdEdit),
193
194    /// Delay an event or todo based on original time
195    Delay(CmdDelay),
196
197    /// Reschedule an event or todo based on current time
198    Reschedule(CmdReschedule),
199
200    /// Add a new event
201    EventNew(CmdEventNew),
202
203    /// Edit an event
204    EventEdit(CmdEventEdit),
205
206    /// Delay an event based on original start
207    EventDelay(CmdEventDelay),
208
209    /// Reschedule an event based on current time
210    EventReschedule(CmdEventReschedule),
211
212    /// List events
213    EventList(CmdEventList),
214
215    /// Add a new todo
216    TodoNew(CmdTodoNew),
217
218    /// Edit a todo
219    TodoEdit(CmdTodoEdit),
220
221    /// Mark a todo as needs-action
222    TodoUndo(CmdTodoUndo),
223
224    /// Mark a todo as completed
225    TodoDone(CmdTodoDone),
226
227    /// Mark a todo as cancelled
228    TodoCancel(CmdTodoCancel),
229
230    /// Delay a todo based on original due
231    TodoDelay(CmdTodoDelay),
232
233    /// Reschedule a todo based on current time
234    TodoReschedule(CmdTodoReschedule),
235
236    /// List todos
237    TodoList(CmdTodoList),
238
239    /// Generate shell completion
240    GenerateCompletion(CmdGenerateCompletion),
241}
242
243impl Commands {
244    /// Run the command with the given configuration
245    #[rustfmt::skip]
246    #[tracing::instrument(skip_all, fields(trace_id = %uuid::Uuid::new_v4()))]
247    pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
248        use Commands::*;
249        tracing::info!(?self, "running command");
250        match self {
251            Dashboard(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
252            New(a)             => Self::run_with(config, |x| a.run(x).boxed()).await,
253            Edit(a)            => Self::run_with(config, |x| a.run(x).boxed()).await,
254            Delay(a)           => Self::run_with(config, |x| a.run(x).boxed()).await,
255            Reschedule(a)      => Self::run_with(config, |x| a.run(x).boxed()).await,
256            EventNew(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
257            EventEdit(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
258            EventDelay(a)      => Self::run_with(config, |x| a.run(x).boxed()).await,
259            EventReschedule(a) => Self::run_with(config, |x| a.run(x).boxed()).await,
260            EventList(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
261            TodoNew(a)         => Self::run_with(config, |x| a.run(x).boxed()).await,
262            TodoEdit(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
263            TodoUndo(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
264            TodoDone(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
265            TodoCancel(a)      => Self::run_with(config, |x| a.run(x).boxed()).await,
266            TodoDelay(a)       => Self::run_with(config, |x| a.run(x).boxed()).await,
267            TodoReschedule(a)  => Self::run_with(config, |x| a.run(x).boxed()).await,
268            TodoList(a)        => Self::run_with(config, |x| a.run(x).boxed()).await,
269            GenerateCompletion(a) => a.run(),
270        }
271    }
272
273    async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
274    where
275        F: for<'a> FnOnce(&'a mut Aim) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
276    {
277        tracing::debug!("parsing configuration...");
278        let (core_config, _config) = parse_config(config).await?;
279
280        tracing::debug!("instantiating...");
281        let mut aim = Aim::new(core_config).await?;
282
283        tracing::debug!("running command...");
284        f(&mut aim).await?;
285
286        tracing::debug!("closing...");
287        aim.close().await?;
288        Ok(())
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use aimcal_core::Id;
295
296    use crate::{cmd_generate_completion::Shell, util::OutputFormat};
297
298    use super::*;
299
300    #[test]
301    fn test_parse_config() {
302        let cli = Cli::try_parse_from(vec!["test", "-c", "/tmp/config.toml"]).unwrap();
303        assert_eq!(cli.config, Some(PathBuf::from("/tmp/config.toml")));
304        assert!(matches!(cli.command, Commands::Dashboard(_)));
305    }
306
307    #[test]
308    fn test_parse_default_dashboard() {
309        let cli = Cli::try_parse_from(vec!["test"]).unwrap();
310        assert!(matches!(cli.command, Commands::Dashboard(_)));
311    }
312
313    #[test]
314    fn test_parse_dashboard() {
315        let cli = Cli::try_parse_from(vec!["test", "dashboard"]).unwrap();
316        assert!(matches!(cli.command, Commands::Dashboard(_)));
317    }
318
319    #[test]
320    fn test_parse_new() {
321        let cli = Cli::try_parse_from(vec!["test", "new"]).unwrap();
322        assert!(matches!(cli.command, Commands::New(_)));
323    }
324
325    #[test]
326    fn test_parse_add() {
327        let cli = Cli::try_parse_from(vec!["test", "add"]).unwrap();
328        assert!(matches!(cli.command, Commands::New(_)));
329    }
330
331    #[test]
332    fn test_parse_edit() {
333        let cli = Cli::try_parse_from(vec!["test", "edit", "id1"]).unwrap();
334        assert!(matches!(cli.command, Commands::Edit(_)));
335    }
336
337    #[test]
338    fn test_parse_event_new() {
339        let cli = Cli::try_parse_from(vec![
340            "test",
341            "event",
342            "new",
343            "a new event",
344            "--start",
345            "2025-01-01 10:00",
346            "--end",
347            "2025-01-01 12:00",
348        ])
349        .unwrap();
350        assert!(matches!(cli.command, Commands::EventNew(_)));
351    }
352
353    #[test]
354    fn test_parse_event_add() {
355        let cli = Cli::try_parse_from(vec![
356            "test",
357            "event",
358            "add",
359            "a new event",
360            "--start",
361            "2025-01-01 10:00",
362            "--end",
363            "2025-01-01 12:00",
364        ])
365        .unwrap();
366        assert!(matches!(cli.command, Commands::EventNew(_)));
367    }
368
369    #[test]
370    fn test_parse_event_edit() {
371        let args = vec!["test", "event", "edit", "some_id", "-s", "new summary"];
372        let cli = Cli::try_parse_from(args).unwrap();
373        match cli.command {
374            Commands::EventEdit(cmd) => {
375                assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
376                assert_eq!(cmd.summary, Some("new summary".to_string()));
377            }
378            _ => panic!("Expected EventEdit command"),
379        }
380    }
381
382    #[test]
383    fn test_parse_event_delay() {
384        let cli = Cli::try_parse_from(vec![
385            "test", "event", "delay", "id1", "id2", "--time", "time",
386        ])
387        .unwrap();
388        match cli.command {
389            Commands::EventDelay(cmd) => {
390                let expected_ids = vec![
391                    Id::ShortIdOrUid("id1".to_string()),
392                    Id::ShortIdOrUid("id2".to_string()),
393                ];
394                assert_eq!(cmd.ids, expected_ids);
395                assert_eq!(cmd.time_anchor, "time".to_string());
396            }
397            _ => panic!("Expected EventDelay command"),
398        }
399    }
400
401    #[test]
402    fn test_parse_event_reschedule() {
403        let cli = Cli::try_parse_from(vec![
404            "test",
405            "event",
406            "reschedule",
407            "id1",
408            "id2",
409            "--time",
410            "time",
411        ])
412        .unwrap();
413        match cli.command {
414            Commands::EventReschedule(cmd) => {
415                let expected_ids = vec![
416                    Id::ShortIdOrUid("id1".to_string()),
417                    Id::ShortIdOrUid("id2".to_string()),
418                ];
419                assert_eq!(cmd.ids, expected_ids);
420                assert_eq!(cmd.time_anchor, "time".to_string());
421            }
422            _ => panic!("Expected EventReschedule command"),
423        }
424    }
425
426    #[test]
427    fn test_parse_event_list() {
428        let args = vec!["test", "event", "list", "--output-format", "json"];
429        let cli = Cli::try_parse_from(args).unwrap();
430        match cli.command {
431            Commands::EventList(cmd) => {
432                assert_eq!(cmd.output_format, OutputFormat::Json);
433            }
434            _ => panic!("Expected EventList command"),
435        }
436    }
437
438    #[test]
439    fn test_parse_todo_new() {
440        let cli = Cli::try_parse_from(vec!["test", "todo", "new", "a new todo"]).unwrap();
441        assert!(matches!(cli.command, Commands::TodoNew(_)));
442    }
443
444    #[test]
445    fn test_parse_todo_add() {
446        let cli = Cli::try_parse_from(vec!["test", "todo", "add", "a new todo"]).unwrap();
447        assert!(matches!(cli.command, Commands::TodoNew(_)));
448    }
449
450    #[test]
451    fn test_parse_todo_edit() {
452        let args = vec!["test", "todo", "edit", "some_id", "-s", "new summary"];
453        let cli = Cli::try_parse_from(args).unwrap();
454        match cli.command {
455            Commands::TodoEdit(cmd) => {
456                assert_eq!(cmd.id, Id::ShortIdOrUid("some_id".to_string()));
457                assert_eq!(cmd.summary, Some("new summary".to_string()));
458            }
459            _ => panic!("Expected TodoEdit command"),
460        }
461    }
462
463    #[test]
464    fn test_parse_todo_undo() {
465        let cli = Cli::try_parse_from(vec!["test", "todo", "undo", "id1", "id2"]).unwrap();
466        match cli.command {
467            Commands::TodoUndo(cmd) => {
468                let expected_ids = vec![
469                    Id::ShortIdOrUid("id1".to_string()),
470                    Id::ShortIdOrUid("id2".to_string()),
471                ];
472                assert_eq!(cmd.ids, expected_ids);
473            }
474            _ => panic!("Expected TodoUndo command"),
475        }
476    }
477
478    #[test]
479    fn test_parse_todo_done() {
480        let cli = Cli::try_parse_from(vec!["test", "todo", "done", "id1", "id2"]).unwrap();
481        match cli.command {
482            Commands::TodoDone(cmd) => {
483                let expected_ids = vec![
484                    Id::ShortIdOrUid("id1".to_string()),
485                    Id::ShortIdOrUid("id2".to_string()),
486                ];
487                assert_eq!(cmd.ids, expected_ids);
488            }
489            _ => panic!("Expected TodoDone command"),
490        }
491    }
492
493    #[test]
494    fn test_parse_todo_cancel() {
495        let cli = Cli::try_parse_from(vec!["test", "todo", "cancel", "id1", "id2"]).unwrap();
496        match cli.command {
497            Commands::TodoCancel(cmd) => {
498                let expected_ids = vec![
499                    Id::ShortIdOrUid("id1".to_string()),
500                    Id::ShortIdOrUid("id2".to_string()),
501                ];
502                assert_eq!(cmd.ids, expected_ids);
503            }
504            _ => panic!("Expected TodoDone command"),
505        }
506    }
507
508    #[test]
509    fn test_parse_todo_delay() {
510        let cli = Cli::try_parse_from(vec![
511            "test", "todo", "delay", "id1", "id2", "id3", "--time", "time",
512        ])
513        .unwrap();
514        match cli.command {
515            Commands::TodoDelay(cmd) => {
516                let expected_ids = vec![
517                    Id::ShortIdOrUid("id1".to_string()),
518                    Id::ShortIdOrUid("id2".to_string()),
519                    Id::ShortIdOrUid("id3".to_string()),
520                ];
521                assert_eq!(cmd.ids, expected_ids);
522                assert_eq!(cmd.time, "time".to_string());
523            }
524            _ => panic!("Expected TodoDelay command"),
525        }
526    }
527
528    #[test]
529    fn test_parse_todo_reschedule() {
530        let cli = Cli::try_parse_from(vec![
531            "test",
532            "todo",
533            "reschedule",
534            "id1",
535            "id2",
536            "--time",
537            "time",
538        ])
539        .unwrap();
540        match cli.command {
541            Commands::TodoReschedule(cmd) => {
542                let expected_ids = vec![
543                    Id::ShortIdOrUid("id1".to_string()),
544                    Id::ShortIdOrUid("id2".to_string()),
545                ];
546                assert_eq!(cmd.ids, expected_ids);
547                assert_eq!(cmd.time, "time".to_string());
548            }
549            _ => panic!("Expected TodoReschedule command"),
550        }
551    }
552
553    #[test]
554    fn test_parse_todo_list() {
555        let args = vec!["test", "todo", "list", "--output-format", "json"];
556        let cli = Cli::try_parse_from(args).unwrap();
557        match cli.command {
558            Commands::TodoList(cmd) => {
559                assert_eq!(cmd.output_format, OutputFormat::Json);
560            }
561            _ => panic!("Expected TodoList command"),
562        }
563    }
564
565    #[test]
566    fn test_parse_done() {
567        let cli = Cli::try_parse_from(vec!["test", "done", "id1", "id2"]).unwrap();
568        match cli.command {
569            Commands::TodoDone(cmd) => {
570                assert_eq!(
571                    cmd.ids,
572                    vec![
573                        Id::ShortIdOrUid("id1".to_string()),
574                        Id::ShortIdOrUid("id2".to_string())
575                    ]
576                );
577            }
578            _ => panic!("Expected TodoDone command"),
579        }
580    }
581
582    #[test]
583    fn test_parse_generate_completions() {
584        let args = vec!["test", "generate-completion", "zsh"];
585        let cli = Cli::try_parse_from(args).unwrap();
586        match cli.command {
587            Commands::GenerateCompletion(cmd) => {
588                assert_eq!(cmd.shell, Shell::Zsh);
589            }
590            _ => panic!("Expected GenerateCompletion command"),
591        }
592    }
593}