Skip to main content

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