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