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