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