aimcal_cli/
cli.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use crate::{
6    Config,
7    cmd_dashboard::CmdDashboard,
8    cmd_event::CmdEventList,
9    cmd_generate_completion::CmdGenerateCompletion,
10    cmd_todo::{CmdTodoDone, CmdTodoEdit, CmdTodoList, CmdTodoNew, CmdTodoUndo},
11    config::APP_NAME,
12    short_id::ShortIdMap,
13};
14use aimcal_core::Aim;
15use clap::{Command, ValueHint, arg, builder::styling, crate_version, value_parser};
16use colored::Colorize;
17use futures::{FutureExt, future::BoxFuture};
18use std::{error::Error, path::PathBuf};
19use tokio::try_join;
20
21/// Run the AIM command-line interface.
22pub async fn run() -> Result<(), Box<dyn Error>> {
23    env_logger::init();
24    if let Err(e) = Cli::parse().run().await {
25        println!("{} {}", "Error:".red(), e);
26    }
27    Ok(())
28}
29
30/// Command-line interface
31#[derive(Debug)]
32pub struct Cli {
33    /// Path to the configuration file
34    pub config: Option<PathBuf>,
35
36    /// The command to execute
37    pub command: Commands,
38}
39
40impl Cli {
41    /// Create the command-line interface
42    pub fn command() -> Command {
43        const STYLES: styling::Styles = styling::Styles::styled()
44            .header(styling::AnsiColor::Green.on_default().bold())
45            .usage(styling::AnsiColor::Green.on_default().bold())
46            .literal(styling::AnsiColor::Blue.on_default().bold())
47            .placeholder(styling::AnsiColor::Cyan.on_default());
48
49        Command::new(APP_NAME)
50            .about("Analyze. Interact. Manage Your Time, with calendar support.")
51            .author("Zexin Yuan <aim@yzx9.xyz>")
52            .version(crate_version!())
53            .styles(STYLES)
54            .subcommand_required(false) // allow default to dashboard
55            .arg_required_else_help(false)
56            .arg(
57                arg!(-c --config [CONFIG] "Path to the configuration file")
58                    .long_help(
59                        "\
60Path to the configuration file. Defaults to $XDG_CONFIG_HOME/aim/config.toml on Linux and MacOS, \
61%LOCALAPPDATA%/aim/config.toml on Windows.",
62                    )
63                    .value_parser(value_parser!(PathBuf))
64                    .value_hint(ValueHint::FilePath),
65            )
66            .subcommand(CmdDashboard::command())
67            .subcommand(
68                Command::new("event")
69                    .alias("e")
70                    .about("Manage your event list")
71                    .arg_required_else_help(true)
72                    .subcommand_required(true)
73                    .subcommand(CmdEventList::command()),
74            )
75            .subcommand(
76                Command::new("todo")
77                    .alias("t")
78                    .about("Manage your todo list")
79                    .arg_required_else_help(true)
80                    .subcommand_required(true)
81                    .subcommand(CmdTodoNew::command())
82                    .subcommand(CmdTodoEdit::command())
83                    .subcommand(CmdTodoDone::command())
84                    .subcommand(CmdTodoUndo::command())
85                    .subcommand(CmdTodoList::command()),
86            )
87            .subcommand(CmdTodoDone::command())
88            .subcommand(CmdTodoUndo::command())
89            .subcommand(CmdGenerateCompletion::command())
90    }
91
92    /// Parse the command-line arguments
93    pub fn parse() -> Self {
94        use Commands::*;
95        let matches = Self::command().get_matches();
96        let command = match matches.subcommand() {
97            Some((CmdDashboard::NAME, _)) => Dashboard(CmdDashboard::parse()),
98            Some(("event", matches)) => match matches.subcommand() {
99                Some(("list", matches)) => EventList(CmdEventList::parse(matches)),
100                _ => unreachable!(),
101            },
102            Some(("todo", matches)) => match matches.subcommand() {
103                Some((CmdTodoNew::NAME, matches)) => TodoNew(CmdTodoNew::parse(matches)),
104                Some((CmdTodoEdit::NAME, matches)) => TodoEdit(CmdTodoEdit::parse(matches)),
105                Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::parse(matches)),
106                Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::parse(matches)),
107                Some((CmdTodoList::NAME, matches)) => TodoList(CmdTodoList::parse(matches)),
108                _ => unreachable!(),
109            },
110            Some((CmdTodoDone::NAME, matches)) => TodoDone(CmdTodoDone::parse(matches)),
111            Some((CmdTodoUndo::NAME, matches)) => TodoUndo(CmdTodoUndo::parse(matches)),
112            Some((CmdGenerateCompletion::NAME, matches)) => {
113                GenerateCompletion(CmdGenerateCompletion::parse(matches))
114            }
115            None => Dashboard(CmdDashboard::parse()),
116            _ => unreachable!(),
117        };
118
119        let config = matches.get_one("config").cloned();
120        Cli { config, command }
121    }
122
123    /// Run the command
124    pub async fn run(self) -> Result<(), Box<dyn Error>> {
125        self.command.run(self.config).await
126    }
127}
128
129/// The commands available in the CLI
130#[derive(Debug, Clone)]
131pub enum Commands {
132    /// Show the dashboard
133    Dashboard(CmdDashboard),
134
135    /// List events
136    EventList(CmdEventList),
137
138    /// Add a new todo
139    TodoNew(CmdTodoNew),
140
141    /// Edit a todo
142    TodoEdit(CmdTodoEdit),
143
144    /// Mark a todo as done
145    TodoDone(CmdTodoDone),
146
147    /// Mark a todo as undone
148    TodoUndo(CmdTodoUndo),
149
150    /// List todos
151    TodoList(CmdTodoList),
152
153    /// Generate shell completion
154    GenerateCompletion(CmdGenerateCompletion),
155}
156
157impl Commands {
158    /// Run the command with the given configuration
159    pub async fn run(self, config: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
160        use Commands::*;
161        match self {
162            Dashboard(a) => Self::run_with(config, |_, y, z| a.run(y, z).boxed()).await,
163            EventList(a) => Self::run_with(config, |_, y, z| a.run(y, z).boxed()).await,
164            TodoNew(a) => Self::run_with(config, |x, y, z| a.run(x, y, z).boxed()).await,
165            TodoEdit(a) => Self::run_with(config, |_, y, z| a.run(y, z).boxed()).await,
166            TodoDone(a) => Self::run_with(config, |_, y, z| a.run(y, z).boxed()).await,
167            TodoUndo(a) => Self::run_with(config, |_, y, z| a.run(y, z).boxed()).await,
168            TodoList(a) => Self::run_with(config, |_, y, z| a.run(y, z).boxed()).await,
169            GenerateCompletion(a) => a.run(),
170        }
171    }
172
173    async fn run_with<F>(config: Option<PathBuf>, f: F) -> Result<(), Box<dyn Error>>
174    where
175        F: for<'a> FnOnce(
176            &'a Config,
177            &'a Aim,
178            &'a ShortIdMap,
179        ) -> BoxFuture<'a, Result<(), Box<dyn Error>>>,
180    {
181        log::debug!("Parsing configuration...");
182        let config = Config::parse(config).await?;
183        let (aim, map) = try_join!(Aim::new(&config.core), ShortIdMap::load_or_new(&config))?;
184
185        f(&config, &aim, &map).await?;
186
187        map.dump(&config).await?;
188        Ok(())
189    }
190}