aimcal_cli/
cli.rs

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