Rust · 117341 bytes Raw Blame History
1 use std::{
2 ffi::{OsStr, OsString},
3 fmt,
4 io::{self, IsTerminal, Write},
5 path::PathBuf,
6 time::Duration,
7 };
8
9 use crossterm::{
10 event::{self, DisableMouseCapture, EnableMouseCapture, Event},
11 execute,
12 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
13 };
14 use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect};
15 use time::{Date, OffsetDateTime, format_description};
16
17 use crate::{
18 agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file},
19 app::{
20 AppState, CreateEventInputResult, EventCopyInputResult, EventCopySubmission,
21 EventDeleteInputResult, EventDeleteSubmission, EventFormMode, HelpInputResult, KeyBindings,
22 KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
23 },
24 calendar::CalendarDate,
25 config::{
26 ConfigError, ConfigHolidaySource, GoogleSetupConfig, MicrosoftSetupConfig, UserConfig,
27 default_config_file, init_config_file, load_discovered_config, load_explicit_config,
28 write_google_setup_config, write_microsoft_setup_config,
29 },
30 providers::{
31 GoogleAccountConfig, GoogleCalendarInfo, GoogleProviderConfig, GoogleProviderRuntime,
32 KeyringGoogleTokenStore, KeyringMicrosoftTokenStore, MicrosoftAccountConfig,
33 MicrosoftCalendarInfo, MicrosoftProviderConfig, MicrosoftProviderRuntime, ProviderConfig,
34 ProviderError, ReqwestMicrosoftHttpClient, inspect_google_token, inspect_token,
35 list_calendars, list_google_calendars, login_device_code_or_browser, login_google_browser,
36 logout, logout_google,
37 },
38 reminders::{
39 ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file,
40 notification_backend_name, run_daemon, run_once, test_notification,
41 },
42 services::{
43 ServiceConfig, ServiceError, SystemCommandRunner, install_service, service_status,
44 uninstall_service,
45 },
46 tui::{
47 AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date,
48 render_app_to_string_with_agenda_source_and_keybindings,
49 },
50 };
51
52 const HELP: &str = concat!(
53 "rcal ",
54 env!("CARGO_PKG_VERSION"),
55 "\n\n",
56 "Usage:\n",
57 " rcal [--config PATH|--no-config] [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]\n",
58 " rcal config init [--path PATH] [--force]\n\n",
59 " rcal providers microsoft auth login --account ID [--browser]\n",
60 " rcal providers microsoft auth logout --account ID\n",
61 " rcal providers microsoft auth inspect --account ID\n",
62 " rcal providers microsoft calendars list --account ID\n",
63 " rcal providers microsoft setup --account ID [--browser] [--calendar ID]\n",
64 " rcal providers microsoft sync [--account ID]\n",
65 " rcal providers microsoft status\n\n",
66 " rcal providers google auth login --account ID\n",
67 " rcal providers google auth logout --account ID\n",
68 " rcal providers google auth inspect --account ID\n",
69 " rcal providers google calendars list --account ID\n",
70 " rcal providers google setup --account ID [--client-id ID] [--client-secret SECRET] [--calendar ID]\n",
71 " rcal providers google sync [--account ID]\n",
72 " rcal providers google status\n\n",
73 " rcal reminders run [--events-file PATH] [--state-file PATH] [--once]\n",
74 " rcal reminders install [--events-file PATH] [--state-file PATH]\n",
75 " rcal reminders uninstall\n",
76 " rcal reminders status\n",
77 " rcal reminders test [--verbose]\n\n",
78 "Options:\n",
79 " --config PATH Load a specific config file.\n",
80 " --no-config Ignore any discovered config file.\n",
81 " --date YYYY-MM-DD Open with the given date selected.\n",
82 " --events-file PATH Read and write local user events at PATH.\n",
83 " --holiday-source off|us-federal|nager\n",
84 " Choose holiday data. Default: us-federal.\n",
85 " --holiday-country CC Country code for --holiday-source nager. Default: US.\n",
86 " -h, --help Show this help.\n",
87 " -V, --version Show version.\n\n",
88 "Config:\n",
89 " rcal config init Write a commented starter TOML config.\n",
90 " Config is discovered at $XDG_CONFIG_HOME/rcal/config.toml, else ~/.config/rcal/config.toml.\n",
91 " CLI flags override config values. Reminder services snapshot resolved paths at install time.\n\n",
92 "Keys:\n",
93 " Arrow keys move selection; Enter opens day view; Esc returns to month; q exits.\n",
94 " ? opens contextual help.\n",
95 " + opens the Create event modal.\n",
96 " In day view, c opens the Copy confirmation for the selected editable event.\n",
97 " In day view, d opens the Delete confirmation for the selected editable event.\n",
98 " In day view, Left/Right move to the previous or next day.\n",
99 " Digits jump immediately; a quick second digit refines the selected day.\n",
100 " Weekday initials jump within the selected week.\n\n",
101 "Mouse:\n",
102 " Left click selects a visible date; double-click a visible date to open day view.\n\n",
103 "Notes:\n",
104 " Provider data is cache-first. Run `rcal providers microsoft sync` or `rcal providers google sync` to refresh it.\n",
105 );
106
107 const VERSION: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "\n");
108 const DIGIT_JUMP_TIMEOUT: Duration = Duration::from_millis(900);
109
110 #[derive(Debug, Clone, PartialEq, Eq)]
111 pub struct AppConfig {
112 pub start_date: CalendarDate,
113 pub events_file: PathBuf,
114 pub holiday_source: HolidaySourceConfig,
115 pub holiday_country: String,
116 pub keybindings: KeyBindings,
117 pub providers: ProviderConfig,
118 }
119
120 impl AppConfig {
121 pub fn new(start_date: CalendarDate) -> Self {
122 Self {
123 start_date,
124 events_file: default_events_file(),
125 holiday_source: HolidaySourceConfig::UsFederal,
126 holiday_country: "US".to_string(),
127 keybindings: KeyBindings::default(),
128 providers: ProviderConfig::default(),
129 }
130 }
131 }
132
133 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
134 pub enum HolidaySourceConfig {
135 Off,
136 UsFederal,
137 Nager,
138 }
139
140 #[derive(Debug, Clone, PartialEq, Eq)]
141 pub enum CliAction {
142 Run(AppConfig),
143 Reminders(ReminderCliAction),
144 Config(ConfigCliAction),
145 Providers(ProviderCliAction),
146 Help,
147 Version,
148 }
149
150 #[derive(Debug, Clone, PartialEq, Eq)]
151 pub enum ConfigCliAction {
152 Init { path: Option<PathBuf>, force: bool },
153 }
154
155 #[derive(Debug, Clone, PartialEq, Eq)]
156 pub enum ProviderCliAction {
157 Microsoft(MicrosoftCliAction),
158 Google(GoogleCliAction),
159 }
160
161 #[derive(Debug, Clone, PartialEq, Eq)]
162 pub enum MicrosoftCliAction {
163 Setup {
164 account: String,
165 browser: bool,
166 calendar: Option<String>,
167 config_path: PathBuf,
168 config: MicrosoftProviderConfig,
169 },
170 AuthLogin {
171 account: String,
172 browser: bool,
173 config: MicrosoftProviderConfig,
174 },
175 AuthLogout {
176 account: String,
177 },
178 AuthInspect {
179 account: String,
180 },
181 CalendarsList {
182 account: String,
183 config: MicrosoftProviderConfig,
184 },
185 Sync {
186 account: Option<String>,
187 config: MicrosoftProviderConfig,
188 },
189 Status {
190 config: MicrosoftProviderConfig,
191 },
192 }
193
194 #[derive(Debug, Clone, PartialEq, Eq)]
195 pub enum GoogleCliAction {
196 Setup {
197 account: String,
198 client_id: Option<String>,
199 client_secret: Option<String>,
200 calendar: Option<String>,
201 config_path: PathBuf,
202 config: GoogleProviderConfig,
203 },
204 AuthLogin {
205 account: String,
206 config: GoogleProviderConfig,
207 },
208 AuthLogout {
209 account: String,
210 },
211 AuthInspect {
212 account: String,
213 },
214 CalendarsList {
215 account: String,
216 config: GoogleProviderConfig,
217 },
218 Sync {
219 account: Option<String>,
220 config: GoogleProviderConfig,
221 },
222 Status {
223 config: GoogleProviderConfig,
224 },
225 }
226
227 #[derive(Debug, Clone, PartialEq, Eq)]
228 pub enum ReminderCliAction {
229 Run(Box<ReminderRunConfig>),
230 Install {
231 events_file: PathBuf,
232 state_file: PathBuf,
233 },
234 Uninstall,
235 Status,
236 Test {
237 verbose: bool,
238 },
239 }
240
241 #[derive(Debug, Clone, PartialEq, Eq)]
242 pub struct ReminderRunConfig {
243 pub events_file: PathBuf,
244 pub state_file: PathBuf,
245 pub providers: ProviderConfig,
246 pub once: bool,
247 }
248
249 #[derive(Debug, Clone, PartialEq, Eq)]
250 pub enum CliError {
251 DuplicateDate,
252 MissingDateValue,
253 DuplicateEventsFile,
254 MissingEventsFileValue,
255 DuplicateHolidaySource,
256 MissingHolidaySourceValue,
257 InvalidHolidaySource(String),
258 DuplicateHolidayCountry,
259 MissingHolidayCountryValue,
260 InvalidHolidayCountry(String),
261 HolidayCountryRequiresNager,
262 DuplicateConfig,
263 MissingConfigValue,
264 ConfigAndNoConfig,
265 MissingConfigCommand,
266 UnknownConfigCommand(String),
267 DuplicateConfigInitPath,
268 MissingConfigInitPathValue,
269 Config(ConfigError),
270 Provider(ProviderError),
271 MissingProviderCommand,
272 UnknownProviderCommand(String),
273 MissingProviderAccount,
274 DuplicateProviderAccount,
275 MissingProviderCalendar,
276 DuplicateProviderCalendar,
277 MissingProviderClientId,
278 DuplicateProviderClientId,
279 MissingProviderClientSecret,
280 DuplicateProviderClientSecret,
281 MissingReminderCommand,
282 UnknownReminderCommand(String),
283 DuplicateStateFile,
284 MissingStateFileValue,
285 UnknownArgument(String),
286 InvalidDate { input: String, reason: String },
287 }
288
289 impl fmt::Display for CliError {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 Self::DuplicateDate => write!(f, "--date may only be provided once"),
293 Self::MissingDateValue => write!(f, "--date requires a value in YYYY-MM-DD format"),
294 Self::DuplicateEventsFile => write!(f, "--events-file may only be provided once"),
295 Self::MissingEventsFileValue => write!(f, "--events-file requires a path"),
296 Self::DuplicateHolidaySource => write!(f, "--holiday-source may only be provided once"),
297 Self::MissingHolidaySourceValue => write!(
298 f,
299 "--holiday-source requires one of: off, us-federal, nager"
300 ),
301 Self::InvalidHolidaySource(value) => write!(
302 f,
303 "invalid --holiday-source value '{value}'; expected off, us-federal, or nager"
304 ),
305 Self::DuplicateHolidayCountry => {
306 write!(f, "--holiday-country may only be provided once")
307 }
308 Self::MissingHolidayCountryValue => {
309 write!(f, "--holiday-country requires a two-letter country code")
310 }
311 Self::InvalidHolidayCountry(value) => {
312 write!(
313 f,
314 "invalid --holiday-country value '{value}'; expected two ASCII letters"
315 )
316 }
317 Self::HolidayCountryRequiresNager => {
318 write!(
319 f,
320 "--holiday-country may only be used with --holiday-source nager"
321 )
322 }
323 Self::DuplicateConfig => write!(f, "--config may only be provided once"),
324 Self::MissingConfigValue => write!(f, "--config requires a path"),
325 Self::ConfigAndNoConfig => write!(f, "--config and --no-config cannot be combined"),
326 Self::MissingConfigCommand => write!(f, "config requires a command: init"),
327 Self::UnknownConfigCommand(command) => write!(f, "unknown config command: {command}"),
328 Self::DuplicateConfigInitPath => {
329 write!(f, "config init --path may only be provided once")
330 }
331 Self::MissingConfigInitPathValue => write!(f, "config init --path requires a path"),
332 Self::Config(err) => write!(f, "{err}"),
333 Self::Provider(err) => write!(f, "{err}"),
334 Self::MissingProviderCommand => {
335 write!(f, "providers requires a command: microsoft or google")
336 }
337 Self::UnknownProviderCommand(command) => {
338 write!(f, "unknown providers command: {command}")
339 }
340 Self::MissingProviderAccount => write!(f, "--account requires a provider account id"),
341 Self::DuplicateProviderAccount => write!(f, "--account may only be provided once"),
342 Self::MissingProviderCalendar => {
343 write!(f, "--calendar requires a provider calendar id")
344 }
345 Self::DuplicateProviderCalendar => write!(f, "--calendar may only be provided once"),
346 Self::MissingProviderClientId => write!(f, "--client-id requires a client id"),
347 Self::DuplicateProviderClientId => write!(f, "--client-id may only be provided once"),
348 Self::MissingProviderClientSecret => {
349 write!(f, "--client-secret requires a client secret")
350 }
351 Self::DuplicateProviderClientSecret => {
352 write!(f, "--client-secret may only be provided once")
353 }
354 Self::MissingReminderCommand => write!(
355 f,
356 "reminders requires one of: run, install, uninstall, status, test"
357 ),
358 Self::UnknownReminderCommand(command) => {
359 write!(f, "unknown reminders command: {command}")
360 }
361 Self::DuplicateStateFile => write!(f, "--state-file may only be provided once"),
362 Self::MissingStateFileValue => write!(f, "--state-file requires a path"),
363 Self::UnknownArgument(arg) => write!(f, "unknown argument: {arg}"),
364 Self::InvalidDate { input, reason } => {
365 write!(f, "invalid --date value '{input}': {reason}")
366 }
367 }
368 }
369 }
370
371 impl std::error::Error for CliError {}
372
373 impl From<ConfigError> for CliError {
374 fn from(err: ConfigError) -> Self {
375 Self::Config(err)
376 }
377 }
378
379 impl From<ProviderError> for CliError {
380 fn from(err: ProviderError) -> Self {
381 Self::Provider(err)
382 }
383 }
384
385 pub fn run_terminal<I>(args: I) -> std::process::ExitCode
386 where
387 I: IntoIterator<Item = OsString>,
388 {
389 let stdout = io::stdout();
390 let stderr = io::stderr();
391
392 if stdout.is_terminal() {
393 run_styled_terminal(args, stdout, stderr)
394 } else {
395 run(args, stdout, stderr)
396 }
397 }
398
399 pub fn run<I, W, E>(args: I, mut stdout: W, mut stderr: E) -> std::process::ExitCode
400 where
401 I: IntoIterator<Item = OsString>,
402 W: Write,
403 E: Write,
404 {
405 match parse_runtime_args(args, default_start_date()) {
406 Ok(CliAction::Run(config)) => {
407 let app = AppState::new(config.start_date);
408 let agenda_source = match agenda_source(&config) {
409 Ok(source) => source,
410 Err(err) => return local_event_error_exit(&mut stderr, err),
411 };
412 let (width, height) = terminal_size();
413 let rendered = render_app_to_string_with_agenda_source_and_keybindings(
414 &app,
415 width,
416 height,
417 &agenda_source,
418 &config.keybindings,
419 );
420 match write!(stdout, "{rendered}") {
421 Ok(()) => std::process::ExitCode::SUCCESS,
422 Err(err) => io_error_exit(&mut stderr, err),
423 }
424 }
425 Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
426 Ok(CliAction::Config(action)) => run_config_action(action, &mut stdout, &mut stderr),
427 Ok(CliAction::Providers(action)) => run_provider_action(action, &mut stdout, &mut stderr),
428 Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
429 Ok(()) => std::process::ExitCode::SUCCESS,
430 Err(err) => io_error_exit(&mut stderr, err),
431 },
432 Ok(CliAction::Version) => match write!(stdout, "{VERSION}") {
433 Ok(()) => std::process::ExitCode::SUCCESS,
434 Err(err) => io_error_exit(&mut stderr, err),
435 },
436 Err(err) => {
437 let _ = writeln!(stderr, "error: {err}\n\n{HELP}");
438 std::process::ExitCode::from(2)
439 }
440 }
441 }
442
443 fn run_styled_terminal<I, W, E>(args: I, mut stdout: W, mut stderr: E) -> std::process::ExitCode
444 where
445 I: IntoIterator<Item = OsString>,
446 W: Write,
447 E: Write,
448 {
449 match parse_runtime_args(args, default_start_date()) {
450 Ok(CliAction::Run(config)) => {
451 let app = AppState::new(config.start_date);
452 let agenda_source = match agenda_source(&config) {
453 Ok(source) => source,
454 Err(err) => return local_event_error_exit(&mut stderr, err),
455 };
456 match run_interactive_terminal(stdout, app, agenda_source, config.keybindings) {
457 Ok(()) => std::process::ExitCode::SUCCESS,
458 Err(err) => io_error_exit(&mut stderr, err),
459 }
460 }
461 Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
462 Ok(CliAction::Config(action)) => run_config_action(action, &mut stdout, &mut stderr),
463 Ok(CliAction::Providers(action)) => run_provider_action(action, &mut stdout, &mut stderr),
464 Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
465 Ok(()) => std::process::ExitCode::SUCCESS,
466 Err(err) => io_error_exit(&mut stderr, err),
467 },
468 Ok(CliAction::Version) => match write!(stdout, "{VERSION}") {
469 Ok(()) => std::process::ExitCode::SUCCESS,
470 Err(err) => io_error_exit(&mut stderr, err),
471 },
472 Err(err) => {
473 let _ = writeln!(stderr, "error: {err}\n\n{HELP}");
474 std::process::ExitCode::from(2)
475 }
476 }
477 }
478
479 pub fn parse_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
480 where
481 I: IntoIterator<Item = OsString>,
482 {
483 parse_args_with_config(args, today, UserConfig::empty(), None)
484 }
485
486 fn parse_runtime_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
487 where
488 I: IntoIterator<Item = OsString>,
489 {
490 let args = args.into_iter().collect::<Vec<_>>();
491 let (args, config_selection) = strip_config_flags(args)?;
492
493 if let Some(action) = early_static_action(&args) {
494 return Ok(action);
495 }
496
497 if let Some(first) = args.first()
498 && first == "config"
499 {
500 return parse_config_args(args.into_iter().skip(1), config_selection.path);
501 }
502
503 let is_setup = is_microsoft_setup_command(&args);
504 let config = match &config_selection {
505 ConfigSelection {
506 no_config: true, ..
507 } => UserConfig::empty(),
508 ConfigSelection {
509 path: Some(path), ..
510 } if is_setup => match load_explicit_config(path.clone()) {
511 Ok(config) => config,
512 Err(ConfigError::Missing { .. }) => UserConfig::empty(),
513 Err(err) => return Err(err.into()),
514 },
515 ConfigSelection {
516 path: Some(path), ..
517 } => load_explicit_config(path.clone())?,
518 ConfigSelection { .. } => load_discovered_config()?,
519 };
520
521 parse_args_with_config(args, today, config, config_selection.path)
522 }
523
524 fn is_microsoft_setup_command(args: &[OsString]) -> bool {
525 matches!(
526 (args.first(), args.get(1), args.get(2)),
527 (Some(first), Some(second), Some(third))
528 if first == "providers" && second == "microsoft" && third == "setup"
529 )
530 }
531
532 fn parse_args_with_config<I>(
533 args: I,
534 today: Date,
535 user_config: UserConfig,
536 explicit_config_path: Option<PathBuf>,
537 ) -> Result<CliAction, CliError>
538 where
539 I: IntoIterator<Item = OsString>,
540 {
541 let args = args.into_iter().collect::<Vec<_>>();
542 if let Some(first) = args.first()
543 && first == "config"
544 {
545 return parse_config_args(args.into_iter().skip(1), explicit_config_path);
546 }
547
548 if let Some(first) = args.first()
549 && first == "reminders"
550 {
551 return parse_reminder_args(args.into_iter().skip(1), &user_config);
552 }
553
554 if let Some(first) = args.first()
555 && first == "providers"
556 {
557 let config_path = explicit_config_path
558 .or_else(|| user_config.path.clone())
559 .unwrap_or_else(default_config_file);
560 return parse_provider_args(args.into_iter().skip(1), &user_config, config_path);
561 }
562
563 parse_calendar_args(args, today, user_config)
564 }
565
566 #[derive(Debug, Clone, PartialEq, Eq)]
567 struct ConfigSelection {
568 path: Option<PathBuf>,
569 no_config: bool,
570 }
571
572 fn strip_config_flags(args: Vec<OsString>) -> Result<(Vec<OsString>, ConfigSelection), CliError> {
573 let mut stripped = Vec::new();
574 let mut config_path = None;
575 let mut no_config = false;
576 let mut args = args.into_iter();
577
578 while let Some(arg) = args.next() {
579 if arg == "--config" {
580 if config_path.is_some() {
581 return Err(CliError::DuplicateConfig);
582 }
583 config_path = Some(PathBuf::from(
584 args.next().ok_or(CliError::MissingConfigValue)?,
585 ));
586 continue;
587 }
588
589 if let Some(value) = arg
590 .to_str()
591 .and_then(|value| value.strip_prefix("--config="))
592 {
593 if config_path.is_some() {
594 return Err(CliError::DuplicateConfig);
595 }
596 config_path = Some(PathBuf::from(value));
597 continue;
598 }
599
600 if arg == "--no-config" {
601 no_config = true;
602 continue;
603 }
604
605 stripped.push(arg);
606 }
607
608 if config_path.is_some() && no_config {
609 return Err(CliError::ConfigAndNoConfig);
610 }
611
612 Ok((
613 stripped,
614 ConfigSelection {
615 path: config_path,
616 no_config,
617 },
618 ))
619 }
620
621 fn early_static_action(args: &[OsString]) -> Option<CliAction> {
622 let first = args.first()?;
623 if first == "--help" || first == "-h" {
624 return Some(CliAction::Help);
625 }
626 if first == "--version" || first == "-V" {
627 return Some(CliAction::Version);
628 }
629 if first == "reminders"
630 && let Some(second) = args.get(1)
631 && (second == "--help" || second == "-h")
632 {
633 return Some(CliAction::Help);
634 }
635 if first == "config"
636 && let Some(second) = args.get(1)
637 && (second == "--help" || second == "-h")
638 {
639 return Some(CliAction::Help);
640 }
641 if first == "providers"
642 && let Some(second) = args.get(1)
643 && (second == "--help" || second == "-h")
644 {
645 return Some(CliAction::Help);
646 }
647
648 None
649 }
650
651 fn parse_calendar_args<I>(
652 args: I,
653 today: Date,
654 user_config: UserConfig,
655 ) -> Result<CliAction, CliError>
656 where
657 I: IntoIterator<Item = OsString>,
658 {
659 let mut start_date = None;
660 let mut events_file = None;
661 let mut holiday_source = None;
662 let mut holiday_country = None;
663 let mut args = args.into_iter();
664
665 while let Some(arg) = args.next() {
666 if arg == "--help" || arg == "-h" {
667 return Ok(CliAction::Help);
668 }
669
670 if arg == "--version" || arg == "-V" {
671 return Ok(CliAction::Version);
672 }
673
674 if arg == "--date" {
675 if start_date.is_some() {
676 return Err(CliError::DuplicateDate);
677 }
678
679 let value = args.next().ok_or(CliError::MissingDateValue)?;
680 start_date = Some(parse_date_arg(&value)?);
681 continue;
682 }
683
684 if let Some(value) = arg.to_str().and_then(|value| value.strip_prefix("--date=")) {
685 if start_date.is_some() {
686 return Err(CliError::DuplicateDate);
687 }
688
689 start_date = Some(parse_date_str(value)?);
690 continue;
691 }
692
693 if arg == "--events-file" {
694 if events_file.is_some() {
695 return Err(CliError::DuplicateEventsFile);
696 }
697
698 let value = args.next().ok_or(CliError::MissingEventsFileValue)?;
699 events_file = Some(PathBuf::from(value));
700 continue;
701 }
702
703 if let Some(value) = arg
704 .to_str()
705 .and_then(|value| value.strip_prefix("--events-file="))
706 {
707 if events_file.is_some() {
708 return Err(CliError::DuplicateEventsFile);
709 }
710
711 events_file = Some(PathBuf::from(value));
712 continue;
713 }
714
715 if arg == "--holiday-source" {
716 if holiday_source.is_some() {
717 return Err(CliError::DuplicateHolidaySource);
718 }
719
720 let value = args.next().ok_or(CliError::MissingHolidaySourceValue)?;
721 holiday_source = Some(parse_holiday_source_arg(&value)?);
722 continue;
723 }
724
725 if let Some(value) = arg
726 .to_str()
727 .and_then(|value| value.strip_prefix("--holiday-source="))
728 {
729 if holiday_source.is_some() {
730 return Err(CliError::DuplicateHolidaySource);
731 }
732
733 holiday_source = Some(parse_holiday_source_str(value)?);
734 continue;
735 }
736
737 if arg == "--holiday-country" {
738 if holiday_country.is_some() {
739 return Err(CliError::DuplicateHolidayCountry);
740 }
741
742 let value = args.next().ok_or(CliError::MissingHolidayCountryValue)?;
743 holiday_country = Some(parse_holiday_country_arg(&value)?);
744 continue;
745 }
746
747 if let Some(value) = arg
748 .to_str()
749 .and_then(|value| value.strip_prefix("--holiday-country="))
750 {
751 if holiday_country.is_some() {
752 return Err(CliError::DuplicateHolidayCountry);
753 }
754
755 holiday_country = Some(parse_holiday_country_str(value)?);
756 continue;
757 }
758
759 return Err(CliError::UnknownArgument(display_arg(&arg)));
760 }
761
762 let cli_holiday_country_was_provided = holiday_country.is_some();
763 let mut config = AppConfig::new(CalendarDate::from(start_date.unwrap_or(today)));
764 if let Some(events_file) = user_config.events_file {
765 config.events_file = events_file;
766 }
767 if let Some(holiday_source) = user_config.holiday_source {
768 config.holiday_source = holiday_source.into();
769 }
770 if let Some(holiday_country) = user_config.holiday_country {
771 config.holiday_country = holiday_country;
772 }
773 config.keybindings = user_config.keybindings;
774 config.providers = user_config.providers;
775
776 if let Some(events_file) = events_file {
777 config.events_file = events_file;
778 }
779 if let Some(holiday_source) = holiday_source {
780 config.holiday_source = holiday_source;
781 }
782 if let Some(holiday_country) = holiday_country {
783 config.holiday_country = holiday_country;
784 }
785 if cli_holiday_country_was_provided && config.holiday_source != HolidaySourceConfig::Nager {
786 return Err(CliError::HolidayCountryRequiresNager);
787 }
788
789 Ok(CliAction::Run(config))
790 }
791
792 impl From<ConfigHolidaySource> for HolidaySourceConfig {
793 fn from(value: ConfigHolidaySource) -> Self {
794 match value {
795 ConfigHolidaySource::Off => Self::Off,
796 ConfigHolidaySource::UsFederal => Self::UsFederal,
797 ConfigHolidaySource::Nager => Self::Nager,
798 }
799 }
800 }
801
802 fn parse_config_args<I>(
803 args: I,
804 explicit_config_path: Option<PathBuf>,
805 ) -> Result<CliAction, CliError>
806 where
807 I: IntoIterator<Item = OsString>,
808 {
809 let mut args = args.into_iter();
810 let command = args.next().ok_or(CliError::MissingConfigCommand)?;
811 let Some(command) = command.to_str() else {
812 return Err(CliError::UnknownConfigCommand(display_arg(&command)));
813 };
814
815 match command {
816 "init" => parse_config_init_args(args, explicit_config_path),
817 "--help" | "-h" => Ok(CliAction::Help),
818 _ => Err(CliError::UnknownConfigCommand(command.to_string())),
819 }
820 }
821
822 fn parse_config_init_args<I>(
823 args: I,
824 explicit_config_path: Option<PathBuf>,
825 ) -> Result<CliAction, CliError>
826 where
827 I: IntoIterator<Item = OsString>,
828 {
829 let mut path = explicit_config_path;
830 let mut force = false;
831 let mut args = args.into_iter();
832
833 while let Some(arg) = args.next() {
834 if arg == "--path" {
835 if path.is_some() {
836 return Err(CliError::DuplicateConfigInitPath);
837 }
838 path = Some(PathBuf::from(
839 args.next().ok_or(CliError::MissingConfigInitPathValue)?,
840 ));
841 continue;
842 }
843
844 if let Some(value) = arg.to_str().and_then(|value| value.strip_prefix("--path=")) {
845 if path.is_some() {
846 return Err(CliError::DuplicateConfigInitPath);
847 }
848 path = Some(PathBuf::from(value));
849 continue;
850 }
851
852 if arg == "--force" {
853 force = true;
854 continue;
855 }
856
857 return Err(CliError::UnknownArgument(display_arg(&arg)));
858 }
859
860 Ok(CliAction::Config(ConfigCliAction::Init { path, force }))
861 }
862
863 fn parse_provider_args<I>(
864 args: I,
865 user_config: &UserConfig,
866 config_path: PathBuf,
867 ) -> Result<CliAction, CliError>
868 where
869 I: IntoIterator<Item = OsString>,
870 {
871 let mut args = args.into_iter();
872 let command = args.next().ok_or(CliError::MissingProviderCommand)?;
873 let Some(command) = command.to_str() else {
874 return Err(CliError::UnknownProviderCommand(display_arg(&command)));
875 };
876
877 match command {
878 "microsoft" => {
879 parse_microsoft_provider_args(args, &user_config.providers.microsoft, config_path)
880 }
881 "google" => parse_google_provider_args(args, &user_config.providers.google, config_path),
882 "--help" | "-h" => Ok(CliAction::Help),
883 _ => Err(CliError::UnknownProviderCommand(command.to_string())),
884 }
885 }
886
887 fn parse_microsoft_provider_args<I>(
888 args: I,
889 config: &MicrosoftProviderConfig,
890 config_path: PathBuf,
891 ) -> Result<CliAction, CliError>
892 where
893 I: IntoIterator<Item = OsString>,
894 {
895 let mut args = args.into_iter();
896 let command = args.next().ok_or(CliError::MissingProviderCommand)?;
897 let Some(command) = command.to_str() else {
898 return Err(CliError::UnknownProviderCommand(display_arg(&command)));
899 };
900
901 match command {
902 "auth" => parse_microsoft_auth_args(args, config),
903 "calendars" => parse_microsoft_calendars_args(args, config),
904 "setup" => parse_microsoft_setup_args(args, config, config_path),
905 "sync" => parse_microsoft_sync_args(args, config),
906 "status" => no_extra_provider_args(
907 args,
908 MicrosoftCliAction::Status {
909 config: config.clone(),
910 },
911 ),
912 "--help" | "-h" => Ok(CliAction::Help),
913 _ => Err(CliError::UnknownProviderCommand(format!(
914 "microsoft {command}"
915 ))),
916 }
917 }
918
919 fn parse_microsoft_auth_args<I>(
920 args: I,
921 config: &MicrosoftProviderConfig,
922 ) -> Result<CliAction, CliError>
923 where
924 I: IntoIterator<Item = OsString>,
925 {
926 let mut args = args.into_iter();
927 let command = args.next().ok_or(CliError::MissingProviderCommand)?;
928 let Some(command) = command.to_str() else {
929 return Err(CliError::UnknownProviderCommand(display_arg(&command)));
930 };
931
932 match command {
933 "login" => {
934 let (account, browser) = parse_account_and_browser(args)?;
935 Ok(CliAction::Providers(ProviderCliAction::Microsoft(
936 MicrosoftCliAction::AuthLogin {
937 account,
938 browser,
939 config: config.clone(),
940 },
941 )))
942 }
943 "logout" => {
944 let account = parse_required_account(args)?;
945 Ok(CliAction::Providers(ProviderCliAction::Microsoft(
946 MicrosoftCliAction::AuthLogout { account },
947 )))
948 }
949 "inspect" => {
950 let account = parse_required_account(args)?;
951 Ok(CliAction::Providers(ProviderCliAction::Microsoft(
952 MicrosoftCliAction::AuthInspect { account },
953 )))
954 }
955 _ => Err(CliError::UnknownProviderCommand(format!(
956 "microsoft auth {command}"
957 ))),
958 }
959 }
960
961 fn parse_microsoft_setup_args<I>(
962 args: I,
963 config: &MicrosoftProviderConfig,
964 config_path: PathBuf,
965 ) -> Result<CliAction, CliError>
966 where
967 I: IntoIterator<Item = OsString>,
968 {
969 let mut browser = false;
970 let mut account = None;
971 let mut calendar = None;
972 let mut args = args.into_iter();
973 while let Some(arg) = args.next() {
974 if arg == "--browser" {
975 browser = true;
976 continue;
977 }
978 if arg == "--calendar" {
979 if calendar.is_some() {
980 return Err(CliError::DuplicateProviderCalendar);
981 }
982 calendar = Some(display_arg(
983 &args.next().ok_or(CliError::MissingProviderCalendar)?,
984 ));
985 continue;
986 }
987 if let Some(value) = arg
988 .to_str()
989 .and_then(|value| value.strip_prefix("--calendar="))
990 {
991 if calendar.is_some() {
992 return Err(CliError::DuplicateProviderCalendar);
993 }
994 calendar = Some(value.to_string());
995 continue;
996 }
997 parse_account_arg(arg, &mut args, &mut account)?;
998 }
999
1000 Ok(CliAction::Providers(ProviderCliAction::Microsoft(
1001 MicrosoftCliAction::Setup {
1002 account: account.ok_or(CliError::MissingProviderAccount)?,
1003 browser,
1004 calendar,
1005 config_path,
1006 config: config.clone(),
1007 },
1008 )))
1009 }
1010
1011 fn parse_microsoft_calendars_args<I>(
1012 args: I,
1013 config: &MicrosoftProviderConfig,
1014 ) -> Result<CliAction, CliError>
1015 where
1016 I: IntoIterator<Item = OsString>,
1017 {
1018 let mut args = args.into_iter();
1019 let command = args.next().ok_or(CliError::MissingProviderCommand)?;
1020 let Some(command) = command.to_str() else {
1021 return Err(CliError::UnknownProviderCommand(display_arg(&command)));
1022 };
1023 match command {
1024 "list" => {
1025 let account = parse_required_account(args)?;
1026 Ok(CliAction::Providers(ProviderCliAction::Microsoft(
1027 MicrosoftCliAction::CalendarsList {
1028 account,
1029 config: config.clone(),
1030 },
1031 )))
1032 }
1033 _ => Err(CliError::UnknownProviderCommand(format!(
1034 "microsoft calendars {command}"
1035 ))),
1036 }
1037 }
1038
1039 fn parse_microsoft_sync_args<I>(
1040 args: I,
1041 config: &MicrosoftProviderConfig,
1042 ) -> Result<CliAction, CliError>
1043 where
1044 I: IntoIterator<Item = OsString>,
1045 {
1046 let account = parse_optional_account(args)?;
1047 Ok(CliAction::Providers(ProviderCliAction::Microsoft(
1048 MicrosoftCliAction::Sync {
1049 account,
1050 config: config.clone(),
1051 },
1052 )))
1053 }
1054
1055 fn no_extra_provider_args<I>(args: I, action: MicrosoftCliAction) -> Result<CliAction, CliError>
1056 where
1057 I: IntoIterator<Item = OsString>,
1058 {
1059 let mut args = args.into_iter();
1060 if let Some(arg) = args.next() {
1061 return Err(CliError::UnknownArgument(display_arg(&arg)));
1062 }
1063 Ok(CliAction::Providers(ProviderCliAction::Microsoft(action)))
1064 }
1065
1066 fn parse_google_provider_args<I>(
1067 args: I,
1068 config: &GoogleProviderConfig,
1069 config_path: PathBuf,
1070 ) -> Result<CliAction, CliError>
1071 where
1072 I: IntoIterator<Item = OsString>,
1073 {
1074 let mut args = args.into_iter();
1075 let command = args.next().ok_or(CliError::MissingProviderCommand)?;
1076 let Some(command) = command.to_str() else {
1077 return Err(CliError::UnknownProviderCommand(display_arg(&command)));
1078 };
1079
1080 match command {
1081 "auth" => parse_google_auth_args(args, config),
1082 "calendars" => parse_google_calendars_args(args, config),
1083 "setup" => parse_google_setup_args(args, config, config_path),
1084 "sync" => parse_google_sync_args(args, config),
1085 "status" => no_extra_google_provider_args(
1086 args,
1087 GoogleCliAction::Status {
1088 config: config.clone(),
1089 },
1090 ),
1091 "--help" | "-h" => Ok(CliAction::Help),
1092 _ => Err(CliError::UnknownProviderCommand(format!(
1093 "google {command}"
1094 ))),
1095 }
1096 }
1097
1098 fn parse_google_auth_args<I>(args: I, config: &GoogleProviderConfig) -> Result<CliAction, CliError>
1099 where
1100 I: IntoIterator<Item = OsString>,
1101 {
1102 let mut args = args.into_iter();
1103 let command = args.next().ok_or(CliError::MissingProviderCommand)?;
1104 let Some(command) = command.to_str() else {
1105 return Err(CliError::UnknownProviderCommand(display_arg(&command)));
1106 };
1107
1108 match command {
1109 "login" => {
1110 let account = parse_required_account(args)?;
1111 Ok(CliAction::Providers(ProviderCliAction::Google(
1112 GoogleCliAction::AuthLogin {
1113 account,
1114 config: config.clone(),
1115 },
1116 )))
1117 }
1118 "logout" => {
1119 let account = parse_required_account(args)?;
1120 Ok(CliAction::Providers(ProviderCliAction::Google(
1121 GoogleCliAction::AuthLogout { account },
1122 )))
1123 }
1124 "inspect" => {
1125 let account = parse_required_account(args)?;
1126 Ok(CliAction::Providers(ProviderCliAction::Google(
1127 GoogleCliAction::AuthInspect { account },
1128 )))
1129 }
1130 _ => Err(CliError::UnknownProviderCommand(format!(
1131 "google auth {command}"
1132 ))),
1133 }
1134 }
1135
1136 fn parse_google_setup_args<I>(
1137 args: I,
1138 config: &GoogleProviderConfig,
1139 config_path: PathBuf,
1140 ) -> Result<CliAction, CliError>
1141 where
1142 I: IntoIterator<Item = OsString>,
1143 {
1144 let mut account = None;
1145 let mut calendar = None;
1146 let mut client_id = None;
1147 let mut client_secret = None;
1148 let mut args = args.into_iter();
1149 while let Some(arg) = args.next() {
1150 if arg == "--calendar" {
1151 if calendar.is_some() {
1152 return Err(CliError::DuplicateProviderCalendar);
1153 }
1154 calendar = Some(display_arg(
1155 &args.next().ok_or(CliError::MissingProviderCalendar)?,
1156 ));
1157 continue;
1158 }
1159 if let Some(value) = arg
1160 .to_str()
1161 .and_then(|value| value.strip_prefix("--calendar="))
1162 {
1163 if calendar.is_some() {
1164 return Err(CliError::DuplicateProviderCalendar);
1165 }
1166 calendar = Some(value.to_string());
1167 continue;
1168 }
1169 if arg == "--client-id" {
1170 if client_id.is_some() {
1171 return Err(CliError::DuplicateProviderClientId);
1172 }
1173 client_id = Some(display_arg(
1174 &args.next().ok_or(CliError::MissingProviderClientId)?,
1175 ));
1176 continue;
1177 }
1178 if let Some(value) = arg
1179 .to_str()
1180 .and_then(|value| value.strip_prefix("--client-id="))
1181 {
1182 if client_id.is_some() {
1183 return Err(CliError::DuplicateProviderClientId);
1184 }
1185 client_id = Some(value.to_string());
1186 continue;
1187 }
1188 if arg == "--client-secret" {
1189 if client_secret.is_some() {
1190 return Err(CliError::DuplicateProviderClientSecret);
1191 }
1192 client_secret = Some(display_arg(
1193 &args.next().ok_or(CliError::MissingProviderClientSecret)?,
1194 ));
1195 continue;
1196 }
1197 if let Some(value) = arg
1198 .to_str()
1199 .and_then(|value| value.strip_prefix("--client-secret="))
1200 {
1201 if client_secret.is_some() {
1202 return Err(CliError::DuplicateProviderClientSecret);
1203 }
1204 client_secret = Some(value.to_string());
1205 continue;
1206 }
1207 parse_account_arg(arg, &mut args, &mut account)?;
1208 }
1209
1210 Ok(CliAction::Providers(ProviderCliAction::Google(
1211 GoogleCliAction::Setup {
1212 account: account.ok_or(CliError::MissingProviderAccount)?,
1213 client_id,
1214 client_secret,
1215 calendar,
1216 config_path,
1217 config: config.clone(),
1218 },
1219 )))
1220 }
1221
1222 fn parse_google_calendars_args<I>(
1223 args: I,
1224 config: &GoogleProviderConfig,
1225 ) -> Result<CliAction, CliError>
1226 where
1227 I: IntoIterator<Item = OsString>,
1228 {
1229 let mut args = args.into_iter();
1230 let command = args.next().ok_or(CliError::MissingProviderCommand)?;
1231 let Some(command) = command.to_str() else {
1232 return Err(CliError::UnknownProviderCommand(display_arg(&command)));
1233 };
1234 match command {
1235 "list" => {
1236 let account = parse_required_account(args)?;
1237 Ok(CliAction::Providers(ProviderCliAction::Google(
1238 GoogleCliAction::CalendarsList {
1239 account,
1240 config: config.clone(),
1241 },
1242 )))
1243 }
1244 _ => Err(CliError::UnknownProviderCommand(format!(
1245 "google calendars {command}"
1246 ))),
1247 }
1248 }
1249
1250 fn parse_google_sync_args<I>(args: I, config: &GoogleProviderConfig) -> Result<CliAction, CliError>
1251 where
1252 I: IntoIterator<Item = OsString>,
1253 {
1254 let account = parse_optional_account(args)?;
1255 Ok(CliAction::Providers(ProviderCliAction::Google(
1256 GoogleCliAction::Sync {
1257 account,
1258 config: config.clone(),
1259 },
1260 )))
1261 }
1262
1263 fn no_extra_google_provider_args<I>(args: I, action: GoogleCliAction) -> Result<CliAction, CliError>
1264 where
1265 I: IntoIterator<Item = OsString>,
1266 {
1267 let mut args = args.into_iter();
1268 if let Some(arg) = args.next() {
1269 return Err(CliError::UnknownArgument(display_arg(&arg)));
1270 }
1271 Ok(CliAction::Providers(ProviderCliAction::Google(action)))
1272 }
1273
1274 fn parse_required_account<I>(args: I) -> Result<String, CliError>
1275 where
1276 I: IntoIterator<Item = OsString>,
1277 {
1278 parse_optional_account(args)?.ok_or(CliError::MissingProviderAccount)
1279 }
1280
1281 fn parse_account_and_browser<I>(args: I) -> Result<(String, bool), CliError>
1282 where
1283 I: IntoIterator<Item = OsString>,
1284 {
1285 let mut browser = false;
1286 let mut account = None;
1287 let mut args = args.into_iter();
1288 while let Some(arg) = args.next() {
1289 if arg == "--browser" {
1290 browser = true;
1291 continue;
1292 }
1293 parse_account_arg(arg, &mut args, &mut account)?;
1294 }
1295 Ok((account.ok_or(CliError::MissingProviderAccount)?, browser))
1296 }
1297
1298 fn parse_optional_account<I>(args: I) -> Result<Option<String>, CliError>
1299 where
1300 I: IntoIterator<Item = OsString>,
1301 {
1302 let mut account = None;
1303 let mut args = args.into_iter();
1304 while let Some(arg) = args.next() {
1305 parse_account_arg(arg, &mut args, &mut account)?;
1306 }
1307 Ok(account)
1308 }
1309
1310 fn parse_account_arg<I>(
1311 arg: OsString,
1312 args: &mut I,
1313 account: &mut Option<String>,
1314 ) -> Result<(), CliError>
1315 where
1316 I: Iterator<Item = OsString>,
1317 {
1318 if arg == "--account" {
1319 if account.is_some() {
1320 return Err(CliError::DuplicateProviderAccount);
1321 }
1322 *account = Some(display_arg(
1323 &args.next().ok_or(CliError::MissingProviderAccount)?,
1324 ));
1325 return Ok(());
1326 }
1327 if let Some(value) = arg
1328 .to_str()
1329 .and_then(|value| value.strip_prefix("--account="))
1330 {
1331 if account.is_some() {
1332 return Err(CliError::DuplicateProviderAccount);
1333 }
1334 *account = Some(value.to_string());
1335 return Ok(());
1336 }
1337 Err(CliError::UnknownArgument(display_arg(&arg)))
1338 }
1339
1340 fn parse_reminder_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
1341 where
1342 I: IntoIterator<Item = OsString>,
1343 {
1344 let mut args = args.into_iter();
1345 let command = args.next().ok_or(CliError::MissingReminderCommand)?;
1346 let Some(command) = command.to_str() else {
1347 return Err(CliError::UnknownReminderCommand(display_arg(&command)));
1348 };
1349
1350 match command {
1351 "run" => parse_reminder_run_args(args, user_config),
1352 "install" => parse_reminder_install_args(args, user_config),
1353 "uninstall" => no_extra_reminder_args(args, ReminderCliAction::Uninstall),
1354 "status" => no_extra_reminder_args(args, ReminderCliAction::Status),
1355 "test" => parse_reminder_test_args(args),
1356 "--help" | "-h" => Ok(CliAction::Help),
1357 _ => Err(CliError::UnknownReminderCommand(command.to_string())),
1358 }
1359 }
1360
1361 fn parse_reminder_run_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
1362 where
1363 I: IntoIterator<Item = OsString>,
1364 {
1365 let mut events_file = None;
1366 let mut state_file = None;
1367 let mut once = false;
1368 let mut args = args.into_iter();
1369
1370 while let Some(arg) = args.next() {
1371 if arg == "--events-file" {
1372 if events_file.is_some() {
1373 return Err(CliError::DuplicateEventsFile);
1374 }
1375 events_file = Some(PathBuf::from(
1376 args.next().ok_or(CliError::MissingEventsFileValue)?,
1377 ));
1378 continue;
1379 }
1380 if let Some(value) = arg
1381 .to_str()
1382 .and_then(|value| value.strip_prefix("--events-file="))
1383 {
1384 if events_file.is_some() {
1385 return Err(CliError::DuplicateEventsFile);
1386 }
1387 events_file = Some(PathBuf::from(value));
1388 continue;
1389 }
1390 if arg == "--state-file" {
1391 if state_file.is_some() {
1392 return Err(CliError::DuplicateStateFile);
1393 }
1394 state_file = Some(PathBuf::from(
1395 args.next().ok_or(CliError::MissingStateFileValue)?,
1396 ));
1397 continue;
1398 }
1399 if let Some(value) = arg
1400 .to_str()
1401 .and_then(|value| value.strip_prefix("--state-file="))
1402 {
1403 if state_file.is_some() {
1404 return Err(CliError::DuplicateStateFile);
1405 }
1406 state_file = Some(PathBuf::from(value));
1407 continue;
1408 }
1409 if arg == "--once" {
1410 once = true;
1411 continue;
1412 }
1413
1414 return Err(CliError::UnknownArgument(display_arg(&arg)));
1415 }
1416
1417 Ok(CliAction::Reminders(ReminderCliAction::Run(Box::new(
1418 ReminderRunConfig {
1419 events_file: events_file.unwrap_or_else(|| {
1420 user_config
1421 .events_file
1422 .clone()
1423 .unwrap_or_else(default_events_file)
1424 }),
1425 state_file: state_file.unwrap_or_else(|| {
1426 user_config
1427 .reminder_state_file
1428 .clone()
1429 .unwrap_or_else(default_state_file)
1430 }),
1431 providers: user_config.providers.clone(),
1432 once,
1433 },
1434 ))))
1435 }
1436
1437 fn parse_reminder_install_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
1438 where
1439 I: IntoIterator<Item = OsString>,
1440 {
1441 let mut events_file = None;
1442 let mut state_file = None;
1443 let mut args = args.into_iter();
1444
1445 while let Some(arg) = args.next() {
1446 if arg == "--events-file" {
1447 if events_file.is_some() {
1448 return Err(CliError::DuplicateEventsFile);
1449 }
1450 events_file = Some(PathBuf::from(
1451 args.next().ok_or(CliError::MissingEventsFileValue)?,
1452 ));
1453 continue;
1454 }
1455 if let Some(value) = arg
1456 .to_str()
1457 .and_then(|value| value.strip_prefix("--events-file="))
1458 {
1459 if events_file.is_some() {
1460 return Err(CliError::DuplicateEventsFile);
1461 }
1462 events_file = Some(PathBuf::from(value));
1463 continue;
1464 }
1465 if arg == "--state-file" {
1466 if state_file.is_some() {
1467 return Err(CliError::DuplicateStateFile);
1468 }
1469 state_file = Some(PathBuf::from(
1470 args.next().ok_or(CliError::MissingStateFileValue)?,
1471 ));
1472 continue;
1473 }
1474 if let Some(value) = arg
1475 .to_str()
1476 .and_then(|value| value.strip_prefix("--state-file="))
1477 {
1478 if state_file.is_some() {
1479 return Err(CliError::DuplicateStateFile);
1480 }
1481 state_file = Some(PathBuf::from(value));
1482 continue;
1483 }
1484
1485 return Err(CliError::UnknownArgument(display_arg(&arg)));
1486 }
1487
1488 Ok(CliAction::Reminders(ReminderCliAction::Install {
1489 events_file: events_file.unwrap_or_else(|| {
1490 user_config
1491 .events_file
1492 .clone()
1493 .unwrap_or_else(default_events_file)
1494 }),
1495 state_file: state_file.unwrap_or_else(|| {
1496 user_config
1497 .reminder_state_file
1498 .clone()
1499 .unwrap_or_else(default_state_file)
1500 }),
1501 }))
1502 }
1503
1504 fn parse_reminder_test_args<I>(args: I) -> Result<CliAction, CliError>
1505 where
1506 I: IntoIterator<Item = OsString>,
1507 {
1508 let mut verbose = false;
1509
1510 for arg in args {
1511 if arg == "--verbose" {
1512 verbose = true;
1513 continue;
1514 }
1515
1516 return Err(CliError::UnknownArgument(display_arg(&arg)));
1517 }
1518
1519 Ok(CliAction::Reminders(ReminderCliAction::Test { verbose }))
1520 }
1521
1522 fn no_extra_reminder_args<I>(args: I, action: ReminderCliAction) -> Result<CliAction, CliError>
1523 where
1524 I: IntoIterator<Item = OsString>,
1525 {
1526 let mut args = args.into_iter();
1527 if let Some(arg) = args.next() {
1528 return Err(CliError::UnknownArgument(display_arg(&arg)));
1529 }
1530
1531 Ok(CliAction::Reminders(action))
1532 }
1533
1534 fn default_start_date() -> Date {
1535 OffsetDateTime::now_local()
1536 .unwrap_or_else(|_| OffsetDateTime::now_utc())
1537 .date()
1538 }
1539
1540 fn terminal_size() -> (u16, u16) {
1541 terminal::size()
1542 .ok()
1543 .filter(|(width, height)| *width > 0 && *height > 0)
1544 .unwrap_or((DEFAULT_RENDER_WIDTH, DEFAULT_RENDER_HEIGHT))
1545 }
1546
1547 fn agenda_source(config: &AppConfig) -> Result<ConfiguredAgendaSource, LocalEventStoreError> {
1548 let holidays = match config.holiday_source {
1549 HolidaySourceConfig::Off => HolidayProvider::off(),
1550 HolidaySourceConfig::UsFederal => HolidayProvider::us_federal(),
1551 HolidaySourceConfig::Nager => HolidayProvider::nager(config.holiday_country.clone()),
1552 };
1553
1554 ConfiguredAgendaSource::from_events_file(config.events_file.clone(), holidays).and_then(
1555 |source| {
1556 source
1557 .with_microsoft_provider(
1558 config.providers.microsoft.clone(),
1559 config.providers.create_target,
1560 )
1561 .and_then(|source| {
1562 source.with_google_provider(
1563 config.providers.google.clone(),
1564 config.providers.create_target,
1565 )
1566 })
1567 },
1568 )
1569 }
1570
1571 fn run_config_action(
1572 action: ConfigCliAction,
1573 stdout: &mut impl Write,
1574 stderr: &mut impl Write,
1575 ) -> std::process::ExitCode {
1576 match action {
1577 ConfigCliAction::Init { path, force } => match init_config_file(path, force) {
1578 Ok(path) => {
1579 let _ = writeln!(stdout, "wrote config {}", path.display());
1580 std::process::ExitCode::SUCCESS
1581 }
1582 Err(err) => config_error_exit(stderr, err),
1583 },
1584 }
1585 }
1586
1587 fn run_provider_action(
1588 action: ProviderCliAction,
1589 stdout: &mut impl Write,
1590 stderr: &mut impl Write,
1591 ) -> std::process::ExitCode {
1592 match action {
1593 ProviderCliAction::Microsoft(action) => run_microsoft_action(action, stdout, stderr),
1594 ProviderCliAction::Google(action) => run_google_action(action, stdout, stderr),
1595 }
1596 }
1597
1598 fn run_microsoft_action(
1599 action: MicrosoftCliAction,
1600 stdout: &mut impl Write,
1601 stderr: &mut impl Write,
1602 ) -> std::process::ExitCode {
1603 let http = ReqwestMicrosoftHttpClient;
1604 let token_store = KeyringMicrosoftTokenStore;
1605 let result = match action {
1606 MicrosoftCliAction::Setup {
1607 account,
1608 browser,
1609 calendar,
1610 config_path,
1611 config,
1612 } => (|| {
1613 let account_config = MicrosoftAccountConfig::new_official(account.clone());
1614 login_device_code_or_browser(&account_config, &http, &token_store, stdout, browser)?;
1615 let calendars = list_calendars(&account_config, &http, &token_store)?;
1616 let calendar = choose_setup_calendar(&calendars, calendar.as_deref())?;
1617 let setup = MicrosoftSetupConfig {
1618 account_id: account.clone(),
1619 calendar_id: calendar.id.clone(),
1620 calendar_name: calendar.name.clone(),
1621 sync_past_days: config.sync_past_days,
1622 sync_future_days: config.sync_future_days,
1623 redirect_port: account_config.redirect_port,
1624 };
1625 let written = write_microsoft_setup_config(Some(config_path), &setup)
1626 .map_err(|err| ProviderError::Config(err.to_string()))?;
1627 let _ = writeln!(
1628 stdout,
1629 "selected Microsoft calendar '{}' ({})",
1630 calendar.name, calendar.id
1631 );
1632 let _ = writeln!(stdout, "wrote config {}", written.display());
1633 let setup_config = microsoft_setup_provider_config(config, account_config, calendar);
1634 let mut runtime = MicrosoftProviderRuntime::load(setup_config)?;
1635 let summary = runtime.sync(
1636 Some(&account),
1637 &http,
1638 &token_store,
1639 CalendarDate::from(default_start_date()),
1640 )?;
1641 let _ = writeln!(
1642 stdout,
1643 "synced accounts={} calendars={} events={}",
1644 summary.accounts, summary.calendars, summary.events
1645 );
1646 Ok(())
1647 })(),
1648 MicrosoftCliAction::AuthLogin {
1649 account,
1650 browser,
1651 config,
1652 } => {
1653 let Some(account_config) = config.account(&account) else {
1654 return provider_error_exit(
1655 stderr,
1656 ProviderError::Config(format!(
1657 "Microsoft account '{account}' is not configured"
1658 )),
1659 );
1660 };
1661 login_device_code_or_browser(account_config, &http, &token_store, stdout, browser)
1662 }
1663 MicrosoftCliAction::AuthLogout { account } => logout(&account, &token_store).map(|()| {
1664 let _ = writeln!(stdout, "removed Microsoft credentials for '{account}'");
1665 }),
1666 MicrosoftCliAction::AuthInspect { account } => {
1667 inspect_token(&account, &token_store).map(|inspection| {
1668 let _ = writeln!(
1669 stdout,
1670 "account={} authenticated=true",
1671 inspection.account_id
1672 );
1673 let _ = writeln!(stdout, "token_format={}", inspection.token_format);
1674 let _ = writeln!(
1675 stdout,
1676 "aud={}",
1677 inspection.audience.as_deref().unwrap_or("<missing>")
1678 );
1679 let _ = writeln!(
1680 stdout,
1681 "scp={}",
1682 inspection.scopes.as_deref().unwrap_or("<missing>")
1683 );
1684 let _ = writeln!(
1685 stdout,
1686 "roles={}",
1687 if inspection.roles.is_empty() {
1688 "<missing>".to_string()
1689 } else {
1690 inspection.roles.join(",")
1691 }
1692 );
1693 let _ = writeln!(
1694 stdout,
1695 "tid={}",
1696 inspection.tenant_id.as_deref().unwrap_or("<missing>")
1697 );
1698 let _ = writeln!(
1699 stdout,
1700 "iss={}",
1701 inspection.issuer.as_deref().unwrap_or("<missing>")
1702 );
1703 let _ = writeln!(
1704 stdout,
1705 "appid={}",
1706 inspection.app_id.as_deref().unwrap_or("<missing>")
1707 );
1708 let _ = writeln!(
1709 stdout,
1710 "azp={}",
1711 inspection
1712 .authorized_party
1713 .as_deref()
1714 .unwrap_or("<missing>")
1715 );
1716 let _ = writeln!(
1717 stdout,
1718 "jwt_exp={}",
1719 inspection
1720 .jwt_expires_at_epoch_seconds
1721 .map(|value| value.to_string())
1722 .unwrap_or_else(|| "<missing>".to_string())
1723 );
1724 let _ = writeln!(
1725 stdout,
1726 "stored_exp={}",
1727 inspection.stored_expires_at_epoch_seconds
1728 );
1729 let _ = writeln!(stdout, "has_refresh_token={}", inspection.has_refresh_token);
1730 })
1731 }
1732 MicrosoftCliAction::CalendarsList { account, config } => {
1733 let Some(account_config) = config.account(&account) else {
1734 return provider_error_exit(
1735 stderr,
1736 ProviderError::Config(format!(
1737 "Microsoft account '{account}' is not configured"
1738 )),
1739 );
1740 };
1741 list_calendars(account_config, &http, &token_store).map(|calendars| {
1742 for calendar in calendars {
1743 let _ = writeln!(
1744 stdout,
1745 "{}\t{}\tcan_edit={}\tdefault={}",
1746 calendar.id, calendar.name, calendar.can_edit, calendar.is_default
1747 );
1748 }
1749 })
1750 }
1751 MicrosoftCliAction::Sync { account, config } => {
1752 let mut runtime = match MicrosoftProviderRuntime::load(config) {
1753 Ok(runtime) => runtime,
1754 Err(err) => return provider_error_exit(stderr, err),
1755 };
1756 runtime
1757 .sync(
1758 account.as_deref(),
1759 &http,
1760 &token_store,
1761 CalendarDate::from(default_start_date()),
1762 )
1763 .map(|summary| {
1764 let _ = writeln!(
1765 stdout,
1766 "synced accounts={} calendars={} events={}",
1767 summary.accounts, summary.calendars, summary.events
1768 );
1769 })
1770 }
1771 MicrosoftCliAction::Status { config } => {
1772 let runtime = match MicrosoftProviderRuntime::load(config.clone()) {
1773 Ok(runtime) => runtime,
1774 Err(err) => return provider_error_exit(stderr, err),
1775 };
1776 let status = runtime.status(&token_store);
1777 let _ = writeln!(
1778 stdout,
1779 "enabled={} cache={} cached_events={}",
1780 status.enabled,
1781 status.cache_file.display(),
1782 status.event_count
1783 );
1784 for account in status.accounts {
1785 let _ = writeln!(
1786 stdout,
1787 "account={} authenticated={} calendars={}",
1788 account.id,
1789 account.authenticated,
1790 account.calendars.join(",")
1791 );
1792 }
1793 Ok(())
1794 }
1795 };
1796
1797 match result {
1798 Ok(()) => std::process::ExitCode::SUCCESS,
1799 Err(err) => provider_error_exit(stderr, err),
1800 }
1801 }
1802
1803 fn run_google_action(
1804 action: GoogleCliAction,
1805 stdout: &mut impl Write,
1806 stderr: &mut impl Write,
1807 ) -> std::process::ExitCode {
1808 let http = ReqwestMicrosoftHttpClient;
1809 let token_store = KeyringGoogleTokenStore;
1810 let result = match action {
1811 GoogleCliAction::Setup {
1812 account,
1813 client_id,
1814 client_secret,
1815 calendar,
1816 config_path,
1817 config,
1818 } => (|| {
1819 let account_config = match &client_id {
1820 Some(client_id) => GoogleAccountConfig::new(
1821 account.clone(),
1822 client_id.clone(),
1823 client_secret.clone(),
1824 ),
1825 None => {
1826 let mut account_config = GoogleAccountConfig::new_official(account.clone())?;
1827 if client_secret.is_some() {
1828 account_config.client_secret = client_secret.clone();
1829 }
1830 account_config
1831 }
1832 };
1833 login_google_browser(&account_config, &http, &token_store, stdout)?;
1834 let calendars = list_google_calendars(&account_config, &http, &token_store)?;
1835 let calendar = choose_google_setup_calendar(&calendars, calendar.as_deref())?;
1836 let setup = GoogleSetupConfig {
1837 account_id: account.clone(),
1838 client_id,
1839 client_secret,
1840 calendar_id: calendar.id.clone(),
1841 calendar_name: calendar.name.clone(),
1842 sync_past_days: config.sync_past_days,
1843 sync_future_days: config.sync_future_days,
1844 redirect_port: account_config.redirect_port,
1845 };
1846 let written = write_google_setup_config(Some(config_path), &setup)
1847 .map_err(|err| ProviderError::Config(err.to_string()))?;
1848 let _ = writeln!(
1849 stdout,
1850 "selected Google calendar '{}' ({})",
1851 calendar.name, calendar.id
1852 );
1853 let _ = writeln!(stdout, "wrote config {}", written.display());
1854 let setup_config = google_setup_provider_config(config, account_config, calendar);
1855 let mut runtime = GoogleProviderRuntime::load(setup_config)?;
1856 let summary = runtime.sync(
1857 Some(&account),
1858 &http,
1859 &token_store,
1860 CalendarDate::from(default_start_date()),
1861 )?;
1862 let _ = writeln!(
1863 stdout,
1864 "synced accounts={} calendars={} events={}",
1865 summary.accounts, summary.calendars, summary.events
1866 );
1867 Ok(())
1868 })(),
1869 GoogleCliAction::AuthLogin { account, config } => {
1870 let Some(account_config) = config.account(&account) else {
1871 return provider_error_exit(
1872 stderr,
1873 ProviderError::Config(format!("Google account '{account}' is not configured")),
1874 );
1875 };
1876 login_google_browser(account_config, &http, &token_store, stdout)
1877 }
1878 GoogleCliAction::AuthLogout { account } => {
1879 logout_google(&account, &token_store).map(|()| {
1880 let _ = writeln!(stdout, "removed Google credentials for '{account}'");
1881 })
1882 }
1883 GoogleCliAction::AuthInspect { account } => inspect_google_token(&account, &token_store)
1884 .map(|inspection| {
1885 let _ = writeln!(
1886 stdout,
1887 "account={} authenticated=true",
1888 inspection.account_id
1889 );
1890 let _ = writeln!(
1891 stdout,
1892 "stored_exp={}",
1893 inspection.stored_expires_at_epoch_seconds
1894 );
1895 let _ = writeln!(stdout, "has_refresh_token={}", inspection.has_refresh_token);
1896 }),
1897 GoogleCliAction::CalendarsList { account, config } => {
1898 let Some(account_config) = config.account(&account) else {
1899 return provider_error_exit(
1900 stderr,
1901 ProviderError::Config(format!("Google account '{account}' is not configured")),
1902 );
1903 };
1904 list_google_calendars(account_config, &http, &token_store).map(|calendars| {
1905 for calendar in calendars {
1906 let _ = writeln!(
1907 stdout,
1908 "{}\t{}\tcan_edit={}\tdefault={}",
1909 calendar.id, calendar.name, calendar.can_edit, calendar.is_default
1910 );
1911 }
1912 })
1913 }
1914 GoogleCliAction::Sync { account, config } => {
1915 let mut runtime = match GoogleProviderRuntime::load(config) {
1916 Ok(runtime) => runtime,
1917 Err(err) => return provider_error_exit(stderr, err),
1918 };
1919 runtime
1920 .sync(
1921 account.as_deref(),
1922 &http,
1923 &token_store,
1924 CalendarDate::from(default_start_date()),
1925 )
1926 .map(|summary| {
1927 let _ = writeln!(
1928 stdout,
1929 "synced accounts={} calendars={} events={}",
1930 summary.accounts, summary.calendars, summary.events
1931 );
1932 })
1933 }
1934 GoogleCliAction::Status { config } => {
1935 let runtime = match GoogleProviderRuntime::load(config.clone()) {
1936 Ok(runtime) => runtime,
1937 Err(err) => return provider_error_exit(stderr, err),
1938 };
1939 let status = runtime.status(&token_store);
1940 let _ = writeln!(
1941 stdout,
1942 "enabled={} cache={} cached_events={}",
1943 status.enabled,
1944 status.cache_file.display(),
1945 status.event_count
1946 );
1947 for account in status.accounts {
1948 let _ = writeln!(
1949 stdout,
1950 "account={} authenticated={} calendars={}",
1951 account.id,
1952 account.authenticated,
1953 account.calendars.join(",")
1954 );
1955 }
1956 Ok(())
1957 }
1958 };
1959
1960 match result {
1961 Ok(()) => std::process::ExitCode::SUCCESS,
1962 Err(err) => provider_error_exit(stderr, err),
1963 }
1964 }
1965
1966 fn choose_setup_calendar<'a>(
1967 calendars: &'a [MicrosoftCalendarInfo],
1968 requested: Option<&str>,
1969 ) -> Result<&'a MicrosoftCalendarInfo, ProviderError> {
1970 if let Some(requested) = requested {
1971 let calendar = calendars
1972 .iter()
1973 .find(|calendar| calendar.id == requested)
1974 .ok_or_else(|| {
1975 ProviderError::Config(format!(
1976 "Microsoft calendar '{requested}' was not found for this account"
1977 ))
1978 })?;
1979 if !calendar.can_edit {
1980 return Err(ProviderError::Config(format!(
1981 "Microsoft calendar '{}' is read-only; choose an editable calendar",
1982 calendar.name
1983 )));
1984 }
1985 return Ok(calendar);
1986 }
1987
1988 calendars
1989 .iter()
1990 .find(|calendar| calendar.can_edit && calendar.is_default)
1991 .or_else(|| calendars.iter().find(|calendar| calendar.can_edit))
1992 .ok_or_else(|| {
1993 ProviderError::Config(
1994 "no editable Microsoft calendars were found for this account".to_string(),
1995 )
1996 })
1997 }
1998
1999 fn choose_google_setup_calendar<'a>(
2000 calendars: &'a [GoogleCalendarInfo],
2001 requested: Option<&str>,
2002 ) -> Result<&'a GoogleCalendarInfo, ProviderError> {
2003 if let Some(requested) = requested {
2004 let calendar = calendars
2005 .iter()
2006 .find(|calendar| calendar.id == requested)
2007 .ok_or_else(|| {
2008 ProviderError::Config(format!(
2009 "Google calendar '{requested}' was not found for this account"
2010 ))
2011 })?;
2012 if !calendar.can_edit {
2013 return Err(ProviderError::Config(format!(
2014 "Google calendar '{}' is read-only; choose an editable calendar",
2015 calendar.name
2016 )));
2017 }
2018 return Ok(calendar);
2019 }
2020
2021 calendars
2022 .iter()
2023 .find(|calendar| calendar.can_edit && calendar.is_default)
2024 .or_else(|| calendars.iter().find(|calendar| calendar.can_edit))
2025 .ok_or_else(|| {
2026 ProviderError::Config(
2027 "no editable Google calendars were found for this account".to_string(),
2028 )
2029 })
2030 }
2031
2032 fn microsoft_setup_provider_config(
2033 mut config: MicrosoftProviderConfig,
2034 mut account: MicrosoftAccountConfig,
2035 calendar: &MicrosoftCalendarInfo,
2036 ) -> MicrosoftProviderConfig {
2037 config.enabled = true;
2038 config.default_account = Some(account.id.clone());
2039 config.default_calendar = Some(calendar.id.clone());
2040 account.calendars = vec![calendar.id.clone()];
2041
2042 if let Some(existing) = config
2043 .accounts
2044 .iter_mut()
2045 .find(|existing| existing.id == account.id)
2046 {
2047 *existing = account;
2048 } else {
2049 config.accounts.push(account);
2050 }
2051
2052 config
2053 }
2054
2055 fn google_setup_provider_config(
2056 mut config: GoogleProviderConfig,
2057 mut account: GoogleAccountConfig,
2058 calendar: &GoogleCalendarInfo,
2059 ) -> GoogleProviderConfig {
2060 config.enabled = true;
2061 config.default_account = Some(account.id.clone());
2062 config.default_calendar = Some(calendar.id.clone());
2063 account.calendars = vec![calendar.id.clone()];
2064
2065 if let Some(existing) = config
2066 .accounts
2067 .iter_mut()
2068 .find(|existing| existing.id == account.id)
2069 {
2070 *existing = account;
2071 } else {
2072 config.accounts.push(account);
2073 }
2074
2075 config
2076 }
2077
2078 fn run_reminder_action(
2079 action: ReminderCliAction,
2080 stdout: &mut impl Write,
2081 stderr: &mut impl Write,
2082 ) -> std::process::ExitCode {
2083 match action {
2084 ReminderCliAction::Run(config) => {
2085 let config = *config;
2086 let daemon_config = ReminderDaemonConfig::new(config.events_file, config.state_file)
2087 .with_providers(config.providers);
2088 let mut notifier = SystemNotifier;
2089 if config.once {
2090 match run_once(
2091 &daemon_config,
2092 crate::reminders::current_local_datetime(),
2093 &mut notifier,
2094 ) {
2095 Ok(summary) => {
2096 let _ = writeln!(
2097 stdout,
2098 "delivered={} skipped={} failed={}",
2099 summary.delivered, summary.skipped, summary.failed
2100 );
2101 std::process::ExitCode::SUCCESS
2102 }
2103 Err(err) => reminder_error_exit(stderr, err),
2104 }
2105 } else {
2106 match run_daemon(daemon_config, &mut notifier) {
2107 Ok(()) => std::process::ExitCode::SUCCESS,
2108 Err(err) => reminder_error_exit(stderr, err),
2109 }
2110 }
2111 }
2112 ReminderCliAction::Install {
2113 events_file,
2114 state_file,
2115 } => {
2116 let config = match ServiceConfig::new(events_file, state_file) {
2117 Ok(config) => config,
2118 Err(err) => return service_error_exit(stderr, err),
2119 };
2120 let mut runner = SystemCommandRunner;
2121 match install_service(&config, &mut runner) {
2122 Ok(()) => {
2123 let _ = writeln!(stdout, "installed reminder service");
2124 std::process::ExitCode::SUCCESS
2125 }
2126 Err(err) => service_error_exit(stderr, err),
2127 }
2128 }
2129 ReminderCliAction::Uninstall => {
2130 let mut runner = SystemCommandRunner;
2131 match uninstall_service(&mut runner) {
2132 Ok(()) => {
2133 let _ = writeln!(stdout, "uninstalled reminder service");
2134 std::process::ExitCode::SUCCESS
2135 }
2136 Err(err) => service_error_exit(stderr, err),
2137 }
2138 }
2139 ReminderCliAction::Status => {
2140 let mut runner = SystemCommandRunner;
2141 match service_status(&mut runner) {
2142 Ok(status) => {
2143 let _ = writeln!(stdout, "{status}");
2144 std::process::ExitCode::SUCCESS
2145 }
2146 Err(err) => service_error_exit(stderr, err),
2147 }
2148 }
2149 ReminderCliAction::Test { verbose } => {
2150 let mut notifier = SystemNotifier;
2151 if verbose {
2152 let _ = writeln!(
2153 stdout,
2154 "notification_backend={}",
2155 notification_backend_name()
2156 );
2157 }
2158 match test_notification(&mut notifier) {
2159 Ok(()) => {
2160 let _ = writeln!(stdout, "sent test reminder notification");
2161 std::process::ExitCode::SUCCESS
2162 }
2163 Err(err) => reminder_error_exit(stderr, err),
2164 }
2165 }
2166 }
2167 }
2168
2169 fn run_interactive_terminal<W>(
2170 stdout: W,
2171 app: AppState,
2172 agenda_source: ConfiguredAgendaSource,
2173 keybindings: KeyBindings,
2174 ) -> io::Result<()>
2175 where
2176 W: Write,
2177 {
2178 terminal::enable_raw_mode()?;
2179 let backend = CrosstermBackend::new(stdout);
2180 let mut terminal = match Terminal::new(backend) {
2181 Ok(terminal) => terminal,
2182 Err(err) => {
2183 let _ = terminal::disable_raw_mode();
2184 return Err(err);
2185 }
2186 };
2187
2188 if let Err(err) = execute!(
2189 terminal.backend_mut(),
2190 EnterAlternateScreen,
2191 EnableMouseCapture
2192 ) {
2193 let _ = execute!(
2194 terminal.backend_mut(),
2195 DisableMouseCapture,
2196 LeaveAlternateScreen
2197 );
2198 let _ = terminal::disable_raw_mode();
2199 return Err(err);
2200 }
2201
2202 let result = run_event_loop(&mut terminal, app, agenda_source, keybindings);
2203 let cleanup_result = restore_terminal(&mut terminal);
2204
2205 result.and(cleanup_result)
2206 }
2207
2208 fn run_event_loop<W>(
2209 terminal: &mut Terminal<CrosstermBackend<W>>,
2210 mut app: AppState,
2211 mut agenda_source: ConfiguredAgendaSource,
2212 keybindings: KeyBindings,
2213 ) -> io::Result<()>
2214 where
2215 W: Write,
2216 {
2217 let mut keyboard = KeyboardInput::new(keybindings.clone());
2218 let mut mouse = MouseInput::default();
2219
2220 loop {
2221 terminal.draw(|frame| {
2222 frame.render_widget(
2223 AppView::with_agenda_source_and_keybindings(&app, &agenda_source, &keybindings),
2224 frame.area(),
2225 );
2226 })?;
2227
2228 if app.should_quit() {
2229 return Ok(());
2230 }
2231
2232 let event = if !app.is_creating_event()
2233 && !app.is_choosing_recurring_edit()
2234 && !app.is_confirming_delete()
2235 && !app.is_copying_event()
2236 && !app.is_showing_help()
2237 && keyboard.is_waiting_for_digit()
2238 {
2239 if event::poll(DIGIT_JUMP_TIMEOUT)? {
2240 event::read()?
2241 } else {
2242 keyboard.clear_digit();
2243 continue;
2244 }
2245 } else {
2246 event::read()?
2247 };
2248
2249 match event {
2250 Event::Key(key) => {
2251 mouse.clear();
2252 if app.is_showing_help() {
2253 match app.handle_help_key(key) {
2254 HelpInputResult::Continue | HelpInputResult::Close => {}
2255 }
2256 } else if app.is_confirming_delete() {
2257 match app.handle_delete_choice_key(key) {
2258 EventDeleteInputResult::Continue => {}
2259 EventDeleteInputResult::Cancel => app.close_delete_choice(),
2260 EventDeleteInputResult::Submit(submission) => match submission {
2261 EventDeleteSubmission::Event { event_id }
2262 | EventDeleteSubmission::Series {
2263 series_id: event_id,
2264 } => match agenda_source.delete_event(&event_id) {
2265 Ok(_) => {
2266 app.close_delete_choice();
2267 app.reconcile_day_event_selection(&agenda_source);
2268 }
2269 Err(err) => app.set_delete_error(err.to_string()),
2270 },
2271 EventDeleteSubmission::Occurrence { series_id, anchor } => {
2272 match agenda_source.delete_occurrence(&series_id, anchor) {
2273 Ok(()) => {
2274 app.close_delete_choice();
2275 app.reconcile_day_event_selection(&agenda_source);
2276 }
2277 Err(err) => app.set_delete_error(err.to_string()),
2278 }
2279 }
2280 },
2281 }
2282 } else if app.is_copying_event() {
2283 match app.handle_copy_choice_key(key) {
2284 EventCopyInputResult::Continue => {}
2285 EventCopyInputResult::Cancel => app.close_copy_choice(),
2286 EventCopyInputResult::Submit(submission) => {
2287 let result = match submission {
2288 EventCopySubmission::Event { event_id }
2289 | EventCopySubmission::Series {
2290 series_id: event_id,
2291 } => agenda_source.duplicate_event(&event_id),
2292 EventCopySubmission::Occurrence { series_id, anchor } => {
2293 agenda_source.duplicate_occurrence(&series_id, anchor)
2294 }
2295 };
2296 match result {
2297 Ok(event) => {
2298 app.close_copy_choice();
2299 app.select_day_event_id(event.id);
2300 app.reconcile_day_event_selection(&agenda_source);
2301 }
2302 Err(err) => app.set_copy_error(err.to_string()),
2303 }
2304 }
2305 }
2306 } else if app.is_choosing_recurring_edit() {
2307 match app.handle_recurrence_choice_key(key, &agenda_source) {
2308 RecurrenceChoiceInputResult::Continue => {}
2309 RecurrenceChoiceInputResult::Cancel => app.close_recurrence_choice(),
2310 }
2311 } else if app.is_creating_event() {
2312 match app.handle_create_key(key) {
2313 CreateEventInputResult::Continue => {}
2314 CreateEventInputResult::Cancel => app.close_create_form(),
2315 CreateEventInputResult::Submit(submission) => {
2316 let submission = *submission;
2317 match submission.mode {
2318 EventFormMode::Create => {
2319 match agenda_source.create_event_with_target(
2320 submission.draft,
2321 &submission.target,
2322 ) {
2323 Ok(_) => {
2324 app.close_create_form();
2325 app.reconcile_day_event_selection(&agenda_source);
2326 }
2327 Err(err) => app.set_create_form_error(err.to_string()),
2328 }
2329 }
2330 EventFormMode::Edit { event_id } => {
2331 match agenda_source.update_event_with_target(
2332 &event_id,
2333 submission.draft,
2334 &submission.target,
2335 ) {
2336 Ok(_) => {
2337 app.close_create_form();
2338 app.reconcile_day_event_selection(&agenda_source);
2339 }
2340 Err(err) => app.set_create_form_error(err.to_string()),
2341 }
2342 }
2343 EventFormMode::EditOccurrence { series_id, anchor } => {
2344 match agenda_source.update_occurrence(
2345 &series_id,
2346 anchor,
2347 submission.draft,
2348 ) {
2349 Ok(_) => {
2350 app.close_create_form();
2351 app.reconcile_day_event_selection(&agenda_source);
2352 }
2353 Err(err) => app.set_create_form_error(err.to_string()),
2354 }
2355 }
2356 }
2357 }
2358 }
2359 } else {
2360 let action = keyboard.translate(key);
2361 app.apply_with_agenda_source(action, &agenda_source);
2362 }
2363 }
2364 Event::Mouse(mouse_event) => {
2365 if app.is_creating_event()
2366 || app.is_choosing_recurring_edit()
2367 || app.is_confirming_delete()
2368 || app.is_copying_event()
2369 || app.is_showing_help()
2370 {
2371 continue;
2372 }
2373 keyboard.clear();
2374 let size = terminal.size()?;
2375 let area = Rect::new(0, 0, size.width, size.height);
2376 let target_date =
2377 hit_test_app_date(&app, area, mouse_event.column, mouse_event.row);
2378 let action = mouse.translate(mouse_event, target_date, app.selected_date());
2379 app.apply_with_agenda_source(action, &agenda_source);
2380 }
2381 Event::Resize(_, _) => {
2382 keyboard.clear();
2383 mouse.clear();
2384 }
2385 _ => {}
2386 }
2387 }
2388 }
2389
2390 fn restore_terminal<W>(terminal: &mut Terminal<CrosstermBackend<W>>) -> io::Result<()>
2391 where
2392 W: Write,
2393 {
2394 let raw_result = terminal::disable_raw_mode();
2395 let screen_result = execute!(
2396 terminal.backend_mut(),
2397 DisableMouseCapture,
2398 LeaveAlternateScreen
2399 );
2400 let cursor_result = terminal.show_cursor();
2401
2402 raw_result?;
2403 screen_result?;
2404 cursor_result?;
2405 Ok(())
2406 }
2407
2408 fn parse_date_arg(value: &OsStr) -> Result<Date, CliError> {
2409 let value = value.to_str().ok_or_else(|| CliError::InvalidDate {
2410 input: display_arg(value),
2411 reason: "date must be valid UTF-8".to_string(),
2412 })?;
2413
2414 parse_date_str(value)
2415 }
2416
2417 fn parse_holiday_source_arg(value: &OsStr) -> Result<HolidaySourceConfig, CliError> {
2418 let value = value
2419 .to_str()
2420 .ok_or_else(|| CliError::InvalidHolidaySource("value must be valid UTF-8".to_string()))?;
2421
2422 parse_holiday_source_str(value)
2423 }
2424
2425 fn parse_holiday_source_str(value: &str) -> Result<HolidaySourceConfig, CliError> {
2426 match value {
2427 "off" => Ok(HolidaySourceConfig::Off),
2428 "us-federal" => Ok(HolidaySourceConfig::UsFederal),
2429 "nager" => Ok(HolidaySourceConfig::Nager),
2430 _ => Err(CliError::InvalidHolidaySource(value.to_string())),
2431 }
2432 }
2433
2434 fn parse_holiday_country_arg(value: &OsStr) -> Result<String, CliError> {
2435 let value = value
2436 .to_str()
2437 .ok_or_else(|| CliError::InvalidHolidayCountry("value must be valid UTF-8".to_string()))?;
2438
2439 parse_holiday_country_str(value)
2440 }
2441
2442 fn parse_holiday_country_str(value: &str) -> Result<String, CliError> {
2443 if value.len() == 2 && value.bytes().all(|value| value.is_ascii_alphabetic()) {
2444 Ok(value.to_ascii_uppercase())
2445 } else {
2446 Err(CliError::InvalidHolidayCountry(value.to_string()))
2447 }
2448 }
2449
2450 fn parse_date_str(value: &str) -> Result<Date, CliError> {
2451 let format =
2452 format_description::parse("[year]-[month]-[day]").map_err(|err| CliError::InvalidDate {
2453 input: value.to_string(),
2454 reason: err.to_string(),
2455 })?;
2456
2457 Date::parse(value, &format).map_err(|err| CliError::InvalidDate {
2458 input: value.to_string(),
2459 reason: err.to_string(),
2460 })
2461 }
2462
2463 fn display_arg(value: &OsStr) -> String {
2464 value.to_string_lossy().into_owned()
2465 }
2466
2467 fn io_error_exit(stderr: &mut impl Write, err: io::Error) -> std::process::ExitCode {
2468 let _ = writeln!(stderr, "error: failed to write output: {err}");
2469 std::process::ExitCode::FAILURE
2470 }
2471
2472 fn local_event_error_exit(
2473 stderr: &mut impl Write,
2474 err: LocalEventStoreError,
2475 ) -> std::process::ExitCode {
2476 let _ = writeln!(stderr, "error: failed to load local events: {err}");
2477 std::process::ExitCode::from(2)
2478 }
2479
2480 fn reminder_error_exit(stderr: &mut impl Write, err: ReminderError) -> std::process::ExitCode {
2481 let _ = writeln!(stderr, "error: {err}");
2482 std::process::ExitCode::FAILURE
2483 }
2484
2485 fn config_error_exit(stderr: &mut impl Write, err: ConfigError) -> std::process::ExitCode {
2486 let _ = writeln!(stderr, "error: {err}");
2487 std::process::ExitCode::from(2)
2488 }
2489
2490 fn service_error_exit(stderr: &mut impl Write, err: ServiceError) -> std::process::ExitCode {
2491 let _ = writeln!(stderr, "error: {err}");
2492 std::process::ExitCode::FAILURE
2493 }
2494
2495 fn provider_error_exit(stderr: &mut impl Write, err: ProviderError) -> std::process::ExitCode {
2496 let _ = writeln!(stderr, "error: {err}");
2497 std::process::ExitCode::FAILURE
2498 }
2499
2500 #[cfg(test)]
2501 mod tests {
2502 use super::*;
2503 use std::{
2504 env, fs,
2505 sync::atomic::{AtomicUsize, Ordering},
2506 };
2507
2508 use crate::app::{KeyBindingOverrides, KeyCommand};
2509 use crate::calendar::CalendarMonth;
2510 use crate::providers::{
2511 GoogleAccountConfig, GoogleProviderConfig, MicrosoftAccountConfig, MicrosoftCalendarInfo,
2512 ProviderCreateTarget,
2513 };
2514 use time::Month;
2515
2516 fn date(year: i32, month: Month, day: u8) -> CalendarDate {
2517 CalendarDate::from_ymd(year, month, day).expect("valid test date")
2518 }
2519
2520 fn arg(value: &str) -> OsString {
2521 OsString::from(value)
2522 }
2523
2524 static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
2525
2526 fn temp_path(name: &str) -> PathBuf {
2527 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
2528 env::temp_dir()
2529 .join(format!("rcal-cli-test-{}-{counter}", std::process::id()))
2530 .join(name)
2531 }
2532
2533 fn config_with_paths_and_keys() -> UserConfig {
2534 UserConfig {
2535 path: Some(PathBuf::from("/tmp/rcal/config.toml")),
2536 events_file: Some(PathBuf::from("/tmp/config-events.json")),
2537 holiday_source: Some(ConfigHolidaySource::Nager),
2538 holiday_country: Some("GB".to_string()),
2539 reminder_state_file: Some(PathBuf::from("/tmp/config-state.json")),
2540 keybindings: KeyBindings::with_overrides(KeyBindingOverrides {
2541 create_event: Some(vec!["n".to_string()]),
2542 help: Some(vec!["h".to_string()]),
2543 ..KeyBindingOverrides::default()
2544 })
2545 .expect("test bindings are valid"),
2546 providers: ProviderConfig::default(),
2547 }
2548 }
2549
2550 fn microsoft_provider_config() -> MicrosoftProviderConfig {
2551 MicrosoftProviderConfig {
2552 enabled: true,
2553 default_account: Some("work".to_string()),
2554 default_calendar: Some("cal-1".to_string()),
2555 sync_past_days: 30,
2556 sync_future_days: 365,
2557 cache_file: PathBuf::from("/tmp/microsoft-cache.json"),
2558 accounts: vec![MicrosoftAccountConfig {
2559 id: "work".to_string(),
2560 client_id: "client-id".to_string(),
2561 tenant: "organizations".to_string(),
2562 redirect_port: 8765,
2563 calendars: vec!["cal-1".to_string()],
2564 }],
2565 }
2566 }
2567
2568 fn config_with_microsoft_provider() -> UserConfig {
2569 UserConfig {
2570 providers: ProviderConfig {
2571 create_target: ProviderCreateTarget::Microsoft,
2572 microsoft: microsoft_provider_config(),
2573 google: GoogleProviderConfig::default(),
2574 },
2575 ..UserConfig::empty()
2576 }
2577 }
2578
2579 fn google_provider_config() -> GoogleProviderConfig {
2580 GoogleProviderConfig {
2581 enabled: true,
2582 default_account: Some("personal".to_string()),
2583 default_calendar: Some("primary".to_string()),
2584 sync_past_days: 30,
2585 sync_future_days: 365,
2586 cache_file: PathBuf::from("/tmp/google-cache.json"),
2587 accounts: vec![GoogleAccountConfig {
2588 id: "personal".to_string(),
2589 client_id: "google-client".to_string(),
2590 client_secret: Some("google-secret".to_string()),
2591 redirect_port: 8766,
2592 calendars: vec!["primary".to_string()],
2593 }],
2594 }
2595 }
2596
2597 fn config_with_google_provider() -> UserConfig {
2598 UserConfig {
2599 providers: ProviderConfig {
2600 create_target: ProviderCreateTarget::Google,
2601 microsoft: MicrosoftProviderConfig::default(),
2602 google: google_provider_config(),
2603 },
2604 ..UserConfig::empty()
2605 }
2606 }
2607
2608 #[test]
2609 fn no_args_uses_provided_today() {
2610 let today = date(2026, Month::April, 23);
2611
2612 let action = parse_args([], today.into()).expect("parse succeeds");
2613
2614 assert_eq!(action, CliAction::Run(AppConfig::new(today)));
2615 }
2616
2617 #[test]
2618 fn date_flag_sets_start_date() {
2619 let today = date(2026, Month::April, 23);
2620
2621 let action =
2622 parse_args([arg("--date"), arg("2027-01-02")], today.into()).expect("parse succeeds");
2623
2624 assert_eq!(
2625 action,
2626 CliAction::Run(AppConfig::new(date(2027, Month::January, 2)))
2627 );
2628 }
2629
2630 #[test]
2631 fn date_equals_form_sets_start_date() {
2632 let today = date(2026, Month::April, 23);
2633
2634 let action = parse_args([arg("--date=2027-01-02")], today.into()).expect("parse succeeds");
2635
2636 assert_eq!(
2637 action,
2638 CliAction::Run(AppConfig::new(date(2027, Month::January, 2)))
2639 );
2640 }
2641
2642 #[test]
2643 fn holiday_source_flag_sets_provider() {
2644 let today = date(2026, Month::April, 23);
2645
2646 let action = parse_args([arg("--holiday-source"), arg("off")], today.into())
2647 .expect("parse succeeds");
2648
2649 assert_eq!(
2650 action,
2651 CliAction::Run(AppConfig {
2652 start_date: today,
2653 holiday_source: HolidaySourceConfig::Off,
2654 holiday_country: "US".to_string(),
2655 ..AppConfig::new(today)
2656 })
2657 );
2658 }
2659
2660 #[test]
2661 fn nager_holiday_source_accepts_country_code() {
2662 let today = date(2026, Month::April, 23);
2663
2664 let action = parse_args(
2665 [
2666 arg("--holiday-source=nager"),
2667 arg("--holiday-country"),
2668 arg("gb"),
2669 ],
2670 today.into(),
2671 )
2672 .expect("parse succeeds");
2673
2674 assert_eq!(
2675 action,
2676 CliAction::Run(AppConfig {
2677 start_date: today,
2678 holiday_source: HolidaySourceConfig::Nager,
2679 holiday_country: "GB".to_string(),
2680 ..AppConfig::new(today)
2681 })
2682 );
2683 }
2684
2685 #[test]
2686 fn nager_holiday_country_can_precede_source() {
2687 let today = date(2026, Month::April, 23);
2688
2689 let action = parse_args(
2690 [
2691 arg("--holiday-country=ca"),
2692 arg("--holiday-source"),
2693 arg("nager"),
2694 ],
2695 today.into(),
2696 )
2697 .expect("parse succeeds");
2698
2699 assert_eq!(
2700 action,
2701 CliAction::Run(AppConfig {
2702 start_date: today,
2703 holiday_source: HolidaySourceConfig::Nager,
2704 holiday_country: "CA".to_string(),
2705 ..AppConfig::new(today)
2706 })
2707 );
2708 }
2709
2710 #[test]
2711 fn events_file_flag_sets_path() {
2712 let today = date(2026, Month::April, 23);
2713 let path = PathBuf::from("/tmp/rcal-test-events.json");
2714
2715 let action = parse_args(
2716 [arg("--events-file"), arg("/tmp/rcal-test-events.json")],
2717 today.into(),
2718 )
2719 .expect("parse succeeds");
2720
2721 assert_eq!(
2722 action,
2723 CliAction::Run(AppConfig {
2724 start_date: today,
2725 events_file: path,
2726 ..AppConfig::new(today)
2727 })
2728 );
2729 }
2730
2731 #[test]
2732 fn events_file_options_are_rejected_when_invalid() {
2733 let today = date(2026, Month::April, 23);
2734
2735 assert_eq!(
2736 parse_args([arg("--events-file")], today.into()).expect_err("missing path fails"),
2737 CliError::MissingEventsFileValue
2738 );
2739 assert_eq!(
2740 parse_args(
2741 [
2742 arg("--events-file"),
2743 arg("/tmp/one.json"),
2744 arg("--events-file=/tmp/two.json"),
2745 ],
2746 today.into(),
2747 )
2748 .expect_err("duplicate path fails"),
2749 CliError::DuplicateEventsFile
2750 );
2751 }
2752
2753 #[test]
2754 fn config_values_merge_under_cli_overrides() {
2755 let today = date(2026, Month::April, 23);
2756
2757 let action = parse_args_with_config(
2758 [
2759 arg("--events-file"),
2760 arg("/tmp/cli-events.json"),
2761 arg("--holiday-country"),
2762 arg("ca"),
2763 ],
2764 today.into(),
2765 config_with_paths_and_keys(),
2766 None,
2767 )
2768 .expect("parse succeeds");
2769
2770 let CliAction::Run(config) = action else {
2771 panic!("calendar args should run the app");
2772 };
2773
2774 assert_eq!(config.events_file, PathBuf::from("/tmp/cli-events.json"));
2775 assert_eq!(config.holiday_source, HolidaySourceConfig::Nager);
2776 assert_eq!(config.holiday_country, "CA");
2777 assert_eq!(config.keybindings.display_for(KeyCommand::CreateEvent), "n");
2778 }
2779
2780 #[test]
2781 fn explicit_runtime_config_file_is_loaded() {
2782 let today = date(2026, Month::April, 23);
2783 let path = temp_path("explicit/config.toml");
2784 let root = path
2785 .parent()
2786 .expect("config dir")
2787 .parent()
2788 .expect("test root")
2789 .to_path_buf();
2790 let _ = fs::remove_dir_all(&root);
2791 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
2792 fs::write(
2793 &path,
2794 r#"
2795 [paths]
2796 events_file = "events.json"
2797
2798 [keybindings]
2799 create_event = ["n"]
2800 "#,
2801 )
2802 .expect("config writes");
2803
2804 let action = parse_runtime_args(
2805 [
2806 arg("--config"),
2807 path.as_os_str().to_os_string(),
2808 arg("--date"),
2809 arg("2026-04-23"),
2810 ],
2811 today.into(),
2812 )
2813 .expect("runtime parse succeeds");
2814
2815 let _ = fs::remove_dir_all(&root);
2816 let CliAction::Run(config) = action else {
2817 panic!("calendar args should run the app");
2818 };
2819 assert_eq!(
2820 config.events_file,
2821 path.parent().expect("config dir").join("events.json")
2822 );
2823 assert_eq!(config.keybindings.display_for(KeyCommand::CreateEvent), "n");
2824 }
2825
2826 #[test]
2827 fn global_config_flags_reject_conflicts() {
2828 let today = date(2026, Month::April, 23);
2829
2830 assert_eq!(
2831 parse_runtime_args(
2832 [arg("--config"), arg("/tmp/config.toml"), arg("--no-config")],
2833 today.into(),
2834 )
2835 .expect_err("conflicting config flags fail"),
2836 CliError::ConfigAndNoConfig
2837 );
2838 }
2839
2840 #[test]
2841 fn config_init_args_parse_path_and_force() {
2842 let today = date(2026, Month::April, 23);
2843
2844 let action = parse_args(
2845 [
2846 arg("config"),
2847 arg("init"),
2848 arg("--path=/tmp/rcal/config.toml"),
2849 arg("--force"),
2850 ],
2851 today.into(),
2852 )
2853 .expect("parse succeeds");
2854
2855 assert_eq!(
2856 action,
2857 CliAction::Config(ConfigCliAction::Init {
2858 path: Some(PathBuf::from("/tmp/rcal/config.toml")),
2859 force: true,
2860 })
2861 );
2862 }
2863
2864 #[test]
2865 fn config_init_can_use_global_config_path_without_loading_it() {
2866 let today = date(2026, Month::April, 23);
2867
2868 let action = parse_runtime_args(
2869 [
2870 arg("--config"),
2871 arg("/tmp/nonexistent-rcal-config.toml"),
2872 arg("config"),
2873 arg("init"),
2874 arg("--force"),
2875 ],
2876 today.into(),
2877 )
2878 .expect("config init parses without loading missing config");
2879
2880 assert_eq!(
2881 action,
2882 CliAction::Config(ConfigCliAction::Init {
2883 path: Some(PathBuf::from("/tmp/nonexistent-rcal-config.toml")),
2884 force: true,
2885 })
2886 );
2887 }
2888
2889 #[test]
2890 fn reminder_run_args_set_events_and_state_paths() {
2891 let today = date(2026, Month::April, 23);
2892
2893 let action = parse_args(
2894 [
2895 arg("reminders"),
2896 arg("run"),
2897 arg("--events-file"),
2898 arg("/tmp/events.json"),
2899 arg("--state-file=/tmp/state.json"),
2900 arg("--once"),
2901 ],
2902 today.into(),
2903 )
2904 .expect("parse succeeds");
2905
2906 assert_eq!(
2907 action,
2908 CliAction::Reminders(ReminderCliAction::Run(Box::new(ReminderRunConfig {
2909 events_file: PathBuf::from("/tmp/events.json"),
2910 state_file: PathBuf::from("/tmp/state.json"),
2911 providers: ProviderConfig::default(),
2912 once: true,
2913 })))
2914 );
2915 }
2916
2917 #[test]
2918 fn reminder_commands_use_config_default_paths() {
2919 let today = date(2026, Month::April, 23);
2920
2921 let run_action = parse_args_with_config(
2922 [arg("reminders"), arg("run"), arg("--once")],
2923 today.into(),
2924 config_with_paths_and_keys(),
2925 None,
2926 )
2927 .expect("run parses");
2928 assert_eq!(
2929 run_action,
2930 CliAction::Reminders(ReminderCliAction::Run(Box::new(ReminderRunConfig {
2931 events_file: PathBuf::from("/tmp/config-events.json"),
2932 state_file: PathBuf::from("/tmp/config-state.json"),
2933 providers: ProviderConfig::default(),
2934 once: true,
2935 })))
2936 );
2937
2938 let install_action = parse_args_with_config(
2939 [
2940 arg("reminders"),
2941 arg("install"),
2942 arg("--state-file=/tmp/override-state.json"),
2943 ],
2944 today.into(),
2945 config_with_paths_and_keys(),
2946 None,
2947 )
2948 .expect("install parses");
2949 assert_eq!(
2950 install_action,
2951 CliAction::Reminders(ReminderCliAction::Install {
2952 events_file: PathBuf::from("/tmp/config-events.json"),
2953 state_file: PathBuf::from("/tmp/override-state.json"),
2954 })
2955 );
2956 }
2957
2958 #[test]
2959 fn reminder_install_args_set_events_path() {
2960 let today = date(2026, Month::April, 23);
2961
2962 let action = parse_args(
2963 [
2964 arg("reminders"),
2965 arg("install"),
2966 arg("--events-file=/tmp/events.json"),
2967 ],
2968 today.into(),
2969 )
2970 .expect("parse succeeds");
2971
2972 assert_eq!(
2973 action,
2974 CliAction::Reminders(ReminderCliAction::Install {
2975 events_file: PathBuf::from("/tmp/events.json"),
2976 state_file: default_state_file(),
2977 })
2978 );
2979 }
2980
2981 #[test]
2982 fn reminder_test_accepts_verbose_diagnostic_flag() {
2983 let today = date(2026, Month::April, 23);
2984
2985 let action = parse_args(
2986 [arg("reminders"), arg("test"), arg("--verbose")],
2987 today.into(),
2988 )
2989 .expect("parse succeeds");
2990
2991 assert_eq!(
2992 action,
2993 CliAction::Reminders(ReminderCliAction::Test { verbose: true })
2994 );
2995 }
2996
2997 #[test]
2998 fn reminder_args_are_rejected_when_invalid() {
2999 let today = date(2026, Month::April, 23);
3000
3001 assert_eq!(
3002 parse_args([arg("reminders")], today.into()).expect_err("missing command fails"),
3003 CliError::MissingReminderCommand
3004 );
3005 assert_eq!(
3006 parse_args([arg("reminders"), arg("bogus")], today.into())
3007 .expect_err("unknown command fails"),
3008 CliError::UnknownReminderCommand("bogus".to_string())
3009 );
3010 assert_eq!(
3011 parse_args(
3012 [
3013 arg("reminders"),
3014 arg("run"),
3015 arg("--state-file"),
3016 arg("/tmp/one.json"),
3017 arg("--state-file=/tmp/two.json"),
3018 ],
3019 today.into(),
3020 )
3021 .expect_err("duplicate state path fails"),
3022 CliError::DuplicateStateFile
3023 );
3024 }
3025
3026 #[test]
3027 fn microsoft_provider_commands_parse_configured_account() {
3028 let today = date(2026, Month::April, 23);
3029 let user_config = config_with_microsoft_provider();
3030 let microsoft = user_config.providers.microsoft.clone();
3031
3032 let sync_action = parse_args_with_config(
3033 [
3034 arg("providers"),
3035 arg("microsoft"),
3036 arg("sync"),
3037 arg("--account"),
3038 arg("work"),
3039 ],
3040 today.into(),
3041 user_config.clone(),
3042 None,
3043 )
3044 .expect("sync parses");
3045 assert_eq!(
3046 sync_action,
3047 CliAction::Providers(ProviderCliAction::Microsoft(MicrosoftCliAction::Sync {
3048 account: Some("work".to_string()),
3049 config: microsoft.clone(),
3050 }))
3051 );
3052
3053 let inspect_action = parse_args_with_config(
3054 [
3055 arg("providers"),
3056 arg("microsoft"),
3057 arg("auth"),
3058 arg("inspect"),
3059 arg("--account"),
3060 arg("work"),
3061 ],
3062 today.into(),
3063 user_config.clone(),
3064 None,
3065 )
3066 .expect("inspect parses");
3067 assert_eq!(
3068 inspect_action,
3069 CliAction::Providers(ProviderCliAction::Microsoft(
3070 MicrosoftCliAction::AuthInspect {
3071 account: "work".to_string(),
3072 },
3073 ))
3074 );
3075
3076 let login_action = parse_args_with_config(
3077 [
3078 arg("providers"),
3079 arg("microsoft"),
3080 arg("auth"),
3081 arg("login"),
3082 arg("--account=work"),
3083 arg("--browser"),
3084 ],
3085 today.into(),
3086 user_config,
3087 None,
3088 )
3089 .expect("login parses");
3090 assert_eq!(
3091 login_action,
3092 CliAction::Providers(ProviderCliAction::Microsoft(
3093 MicrosoftCliAction::AuthLogin {
3094 account: "work".to_string(),
3095 browser: true,
3096 config: microsoft,
3097 },
3098 ))
3099 );
3100 }
3101
3102 #[test]
3103 fn google_provider_commands_parse_configured_account() {
3104 let today = date(2026, Month::April, 23);
3105 let user_config = config_with_google_provider();
3106 let google = user_config.providers.google.clone();
3107
3108 let sync_action = parse_args_with_config(
3109 [
3110 arg("providers"),
3111 arg("google"),
3112 arg("sync"),
3113 arg("--account"),
3114 arg("personal"),
3115 ],
3116 today.into(),
3117 user_config.clone(),
3118 None,
3119 )
3120 .expect("sync parses");
3121 assert_eq!(
3122 sync_action,
3123 CliAction::Providers(ProviderCliAction::Google(GoogleCliAction::Sync {
3124 account: Some("personal".to_string()),
3125 config: google.clone(),
3126 }))
3127 );
3128
3129 let login_action = parse_args_with_config(
3130 [
3131 arg("providers"),
3132 arg("google"),
3133 arg("auth"),
3134 arg("login"),
3135 arg("--account=personal"),
3136 ],
3137 today.into(),
3138 user_config,
3139 None,
3140 )
3141 .expect("login parses");
3142 assert_eq!(
3143 login_action,
3144 CliAction::Providers(ProviderCliAction::Google(GoogleCliAction::AuthLogin {
3145 account: "personal".to_string(),
3146 config: google,
3147 }))
3148 );
3149 }
3150
3151 #[test]
3152 fn google_setup_command_parses_client_id_secret_and_calendar() {
3153 let today = date(2026, Month::April, 23);
3154 let user_config = config_with_google_provider();
3155 let google = user_config.providers.google.clone();
3156 let config_path = PathBuf::from("/tmp/rcal/google-setup-config.toml");
3157
3158 let action = parse_args_with_config(
3159 [
3160 arg("providers"),
3161 arg("google"),
3162 arg("setup"),
3163 arg("--account"),
3164 arg("personal"),
3165 arg("--client-id=google-client"),
3166 arg("--client-secret"),
3167 arg("google-secret"),
3168 arg("--calendar=primary"),
3169 ],
3170 today.into(),
3171 user_config,
3172 Some(config_path.clone()),
3173 )
3174 .expect("setup parses");
3175
3176 assert_eq!(
3177 action,
3178 CliAction::Providers(ProviderCliAction::Google(GoogleCliAction::Setup {
3179 account: "personal".to_string(),
3180 client_id: Some("google-client".to_string()),
3181 client_secret: Some("google-secret".to_string()),
3182 calendar: Some("primary".to_string()),
3183 config_path,
3184 config: google,
3185 }))
3186 );
3187 }
3188
3189 #[test]
3190 fn google_setup_command_allows_official_client_defaults() {
3191 let today = date(2026, Month::April, 23);
3192 let user_config = UserConfig::empty();
3193 let google = user_config.providers.google.clone();
3194 let config_path = PathBuf::from("/tmp/rcal/google-official-setup-config.toml");
3195
3196 let action = parse_args_with_config(
3197 [
3198 arg("providers"),
3199 arg("google"),
3200 arg("setup"),
3201 arg("--account"),
3202 arg("personal"),
3203 ],
3204 today.into(),
3205 user_config,
3206 Some(config_path.clone()),
3207 )
3208 .expect("setup parses");
3209
3210 assert_eq!(
3211 action,
3212 CliAction::Providers(ProviderCliAction::Google(GoogleCliAction::Setup {
3213 account: "personal".to_string(),
3214 client_id: None,
3215 client_secret: None,
3216 calendar: None,
3217 config_path,
3218 config: google,
3219 }))
3220 );
3221 }
3222
3223 #[test]
3224 fn microsoft_setup_command_parses_with_calendar_and_config_path() {
3225 let today = date(2026, Month::April, 23);
3226 let user_config = config_with_microsoft_provider();
3227 let microsoft = user_config.providers.microsoft.clone();
3228 let config_path = PathBuf::from("/tmp/rcal/setup-config.toml");
3229
3230 let action = parse_args_with_config(
3231 [
3232 arg("providers"),
3233 arg("microsoft"),
3234 arg("setup"),
3235 arg("--account"),
3236 arg("work"),
3237 arg("--browser"),
3238 arg("--calendar=cal-2"),
3239 ],
3240 today.into(),
3241 user_config,
3242 Some(config_path.clone()),
3243 )
3244 .expect("setup parses");
3245
3246 assert_eq!(
3247 action,
3248 CliAction::Providers(ProviderCliAction::Microsoft(MicrosoftCliAction::Setup {
3249 account: "work".to_string(),
3250 browser: true,
3251 calendar: Some("cal-2".to_string()),
3252 config_path,
3253 config: microsoft,
3254 }))
3255 );
3256 }
3257
3258 #[test]
3259 fn microsoft_setup_allows_missing_explicit_config_file() {
3260 let today = date(2026, Month::April, 23);
3261 let path = temp_path("missing-setup/config.toml");
3262 let root = path
3263 .parent()
3264 .expect("config dir")
3265 .parent()
3266 .expect("test root")
3267 .to_path_buf();
3268 let _ = fs::remove_dir_all(&root);
3269
3270 let action = parse_runtime_args(
3271 [
3272 arg("--config"),
3273 path.as_os_str().to_os_string(),
3274 arg("providers"),
3275 arg("microsoft"),
3276 arg("setup"),
3277 arg("--account"),
3278 arg("work"),
3279 ],
3280 today.into(),
3281 )
3282 .expect("setup parses without a preexisting config");
3283
3284 assert_eq!(
3285 action,
3286 CliAction::Providers(ProviderCliAction::Microsoft(MicrosoftCliAction::Setup {
3287 account: "work".to_string(),
3288 browser: false,
3289 calendar: None,
3290 config_path: path,
3291 config: MicrosoftProviderConfig::default(),
3292 }))
3293 );
3294 }
3295
3296 #[test]
3297 fn microsoft_setup_calendar_selection_prefers_editable_default() {
3298 let calendars = vec![
3299 MicrosoftCalendarInfo {
3300 id: "readonly".to_string(),
3301 name: "Holidays".to_string(),
3302 can_edit: false,
3303 is_default: true,
3304 },
3305 MicrosoftCalendarInfo {
3306 id: "cal".to_string(),
3307 name: "Calendar".to_string(),
3308 can_edit: true,
3309 is_default: true,
3310 },
3311 MicrosoftCalendarInfo {
3312 id: "other".to_string(),
3313 name: "Other".to_string(),
3314 can_edit: true,
3315 is_default: false,
3316 },
3317 ];
3318
3319 let calendar =
3320 choose_setup_calendar(&calendars, None).expect("default editable calendar selected");
3321
3322 assert_eq!(calendar.id, "cal");
3323 }
3324
3325 #[test]
3326 fn microsoft_setup_calendar_selection_uses_first_editable_fallback() {
3327 let calendars = vec![
3328 MicrosoftCalendarInfo {
3329 id: "readonly".to_string(),
3330 name: "Holidays".to_string(),
3331 can_edit: false,
3332 is_default: true,
3333 },
3334 MicrosoftCalendarInfo {
3335 id: "work".to_string(),
3336 name: "Work".to_string(),
3337 can_edit: true,
3338 is_default: false,
3339 },
3340 ];
3341
3342 let calendar =
3343 choose_setup_calendar(&calendars, None).expect("first editable calendar selected");
3344
3345 assert_eq!(calendar.id, "work");
3346 }
3347
3348 #[test]
3349 fn microsoft_setup_calendar_selection_rejects_missing_or_readonly_requested_calendar() {
3350 let calendars = vec![MicrosoftCalendarInfo {
3351 id: "readonly".to_string(),
3352 name: "Holidays".to_string(),
3353 can_edit: false,
3354 is_default: false,
3355 }];
3356
3357 let missing =
3358 choose_setup_calendar(&calendars, Some("missing")).expect_err("missing calendar fails");
3359 let readonly = choose_setup_calendar(&calendars, Some("readonly"))
3360 .expect_err("readonly calendar fails");
3361
3362 assert!(missing.to_string().contains("was not found"));
3363 assert!(readonly.to_string().contains("read-only"));
3364 }
3365
3366 #[test]
3367 fn microsoft_provider_args_reject_missing_and_duplicate_accounts() {
3368 let today = date(2026, Month::April, 23);
3369
3370 assert_eq!(
3371 parse_args(
3372 [
3373 arg("providers"),
3374 arg("microsoft"),
3375 arg("auth"),
3376 arg("login")
3377 ],
3378 today.into(),
3379 )
3380 .expect_err("missing account fails"),
3381 CliError::MissingProviderAccount
3382 );
3383 assert_eq!(
3384 parse_args(
3385 [
3386 arg("providers"),
3387 arg("microsoft"),
3388 arg("sync"),
3389 arg("--account=one"),
3390 arg("--account=two"),
3391 ],
3392 today.into(),
3393 )
3394 .expect_err("duplicate account fails"),
3395 CliError::DuplicateProviderAccount
3396 );
3397 }
3398
3399 #[test]
3400 fn invalid_holiday_options_are_rejected() {
3401 let today = date(2026, Month::April, 23);
3402
3403 assert_eq!(
3404 parse_args([arg("--holiday-source"), arg("network")], today.into())
3405 .expect_err("invalid source fails"),
3406 CliError::InvalidHolidaySource("network".to_string())
3407 );
3408 assert_eq!(
3409 parse_args([arg("--holiday-country"), arg("USA")], today.into())
3410 .expect_err("invalid country fails"),
3411 CliError::InvalidHolidayCountry("USA".to_string())
3412 );
3413 }
3414
3415 #[test]
3416 fn holiday_country_requires_nager_source() {
3417 let today = date(2026, Month::April, 23);
3418
3419 assert_eq!(
3420 parse_args([arg("--holiday-country"), arg("GB")], today.into())
3421 .expect_err("country without Nager fails"),
3422 CliError::HolidayCountryRequiresNager
3423 );
3424 assert_eq!(
3425 parse_args(
3426 [
3427 arg("--holiday-source"),
3428 arg("off"),
3429 arg("--holiday-country"),
3430 arg("GB"),
3431 ],
3432 today.into(),
3433 )
3434 .expect_err("country with non-Nager source fails"),
3435 CliError::HolidayCountryRequiresNager
3436 );
3437 }
3438
3439 #[test]
3440 fn invalid_date_is_rejected() {
3441 let today = date(2026, Month::April, 23);
3442
3443 let err = parse_args([arg("--date"), arg("2026-02-30")], today.into())
3444 .expect_err("invalid dates fail");
3445
3446 assert!(matches!(err, CliError::InvalidDate { .. }));
3447 }
3448
3449 #[test]
3450 fn missing_date_value_is_rejected() {
3451 let today = date(2026, Month::April, 23);
3452
3453 let err = parse_args([arg("--date")], today.into()).expect_err("missing values fail");
3454
3455 assert_eq!(err, CliError::MissingDateValue);
3456 }
3457
3458 #[test]
3459 fn duplicate_date_is_rejected() {
3460 let today = date(2026, Month::April, 23);
3461
3462 let err = parse_args(
3463 [
3464 arg("--date"),
3465 arg("2026-04-23"),
3466 arg("--date"),
3467 arg("2026-04-24"),
3468 ],
3469 today.into(),
3470 )
3471 .expect_err("duplicate dates fail");
3472
3473 assert_eq!(err, CliError::DuplicateDate);
3474 }
3475
3476 #[test]
3477 fn date_flag_seeds_calendar_month_selection() {
3478 let today = date(2026, Month::April, 23);
3479
3480 let action =
3481 parse_args([arg("--date"), arg("2027-01-02")], today.into()).expect("parse succeeds");
3482 let CliAction::Run(config) = action else {
3483 panic!("date flag should produce run config");
3484 };
3485
3486 let month = CalendarMonth::for_launch_date(config.start_date);
3487
3488 assert_eq!(month.current.year, 2027);
3489 assert_eq!(month.current.month, Month::January);
3490 assert_eq!(
3491 month.selected_cell().map(|cell| cell.date),
3492 Some(date(2027, Month::January, 2))
3493 );
3494 }
3495 }
3496