Rust · 41672 bytes Raw Blame History
1 use std::{
2 env,
3 error::Error,
4 fmt, fs,
5 path::{Path, PathBuf},
6 };
7
8 use serde::Deserialize;
9
10 use crate::{
11 app::{KeyBindingError, KeyBindingOverrides, KeyBindings},
12 providers::{
13 GoogleAccountConfig, GoogleProviderConfig, MICROSOFT_DEFAULT_TENANT,
14 MICROSOFT_OFFICIAL_CLIENT_ID, MicrosoftAccountConfig, MicrosoftProviderConfig,
15 ProviderConfig, ProviderCreateTarget, google_official_client_config,
16 },
17 };
18
19 pub const DEFAULT_CONFIG_TOML: &str = r#"# rcal configuration
20 # Generate this file with `rcal config init`.
21
22 [paths]
23 # Local user-created events.
24 events_file = "~/.local/share/rcal/events.json"
25
26 [holidays]
27 # One of: "off", "us-federal", "nager".
28 source = "us-federal"
29 # Two-letter country code. Only used by the Nager.Date holiday source.
30 country = "US"
31
32 [reminders]
33 # Delivered/skipped reminder state. Services snapshot this path at install time.
34 state_file = "~/.local/state/rcal/reminders-state.json"
35
36 [providers]
37 # Where newly created events go when provider support is enabled:
38 # "local", "microsoft", or "google".
39 create_target = "local"
40
41 [providers.microsoft]
42 # Microsoft Graph provider for Outlook / Microsoft 365 calendars.
43 # rcal ships an official public/native Microsoft client ID.
44 # Advanced users may override client_id and tenant inside an account block.
45 enabled = false
46 default_account = "work"
47 sync_past_days = 30
48 sync_future_days = 365
49 # Provider cache is separate from the local events file.
50 # cache_file = "~/.cache/rcal/microsoft-cache.json"
51
52 [[providers.microsoft.accounts]]
53 id = "work"
54 redirect_port = 8765
55 calendars = []
56
57 [providers.google]
58 # Google Calendar provider.
59 # rcal release builds can ship an official Google OAuth Desktop client.
60 # Advanced users may override client_id/client_secret inside an account block.
61 enabled = false
62 default_account = "personal"
63 sync_past_days = 30
64 sync_future_days = 365
65 # Provider cache is separate from the local events file.
66 # cache_file = "~/.cache/rcal/google-cache.json"
67
68 [[providers.google.accounts]]
69 id = "personal"
70 # client_id = "GOOGLE_CLIENT_ID"
71 # client_secret = "GOOGLE_CLIENT_SECRET"
72 redirect_port = 8766
73 calendars = []
74
75 [keybindings]
76 # Normal month/day app commands. Modal/form editing keys are fixed for now.
77 move_left = ["left"]
78 move_right = ["right"]
79 move_up = ["up"]
80 move_down = ["down"]
81 open_day_or_edit = ["enter"]
82 close_day = ["esc"]
83 create_event = ["+"]
84 delete_event = ["d"]
85 copy_event = ["c"]
86 help = ["?"]
87 quit = ["q", "ctrl-c"]
88
89 jump_monday = ["m"]
90 jump_tuesday = ["tu"]
91 jump_wednesday = ["w"]
92 jump_thursday = ["th"]
93 jump_friday = ["f"]
94 jump_saturday = ["sa"]
95 jump_sunday = ["su"]
96 "#;
97
98 #[derive(Debug, Clone, PartialEq, Eq)]
99 pub struct UserConfig {
100 pub path: Option<PathBuf>,
101 pub events_file: Option<PathBuf>,
102 pub holiday_source: Option<ConfigHolidaySource>,
103 pub holiday_country: Option<String>,
104 pub reminder_state_file: Option<PathBuf>,
105 pub keybindings: KeyBindings,
106 pub providers: ProviderConfig,
107 }
108
109 impl UserConfig {
110 pub fn empty() -> Self {
111 Self {
112 path: None,
113 events_file: None,
114 holiday_source: None,
115 holiday_country: None,
116 reminder_state_file: None,
117 keybindings: KeyBindings::default(),
118 providers: ProviderConfig::default(),
119 }
120 }
121 }
122
123 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
124 pub enum ConfigHolidaySource {
125 Off,
126 UsFederal,
127 Nager,
128 }
129
130 pub fn default_config_file() -> PathBuf {
131 default_config_file_for(env::var_os("XDG_CONFIG_HOME"), env::var_os("HOME"))
132 }
133
134 fn default_config_file_for(
135 xdg_config_home: Option<impl Into<PathBuf>>,
136 home: Option<impl Into<PathBuf>>,
137 ) -> PathBuf {
138 if let Some(xdg_config_home) = xdg_config_home {
139 return xdg_config_home.into().join("rcal").join("config.toml");
140 }
141
142 if let Some(home) = home {
143 return home.into().join(".config").join("rcal").join("config.toml");
144 }
145
146 env::temp_dir().join("rcal").join("config.toml")
147 }
148
149 pub fn load_discovered_config() -> Result<UserConfig, ConfigError> {
150 let path = default_config_file();
151 if !path.exists() {
152 return Ok(UserConfig::empty());
153 }
154
155 load_config_file(&path)
156 }
157
158 pub fn load_explicit_config(path: impl Into<PathBuf>) -> Result<UserConfig, ConfigError> {
159 let path = expand_user_path(path.into())?;
160 if !path.exists() {
161 return Err(ConfigError::Missing { path });
162 }
163
164 load_config_file(&path)
165 }
166
167 pub fn load_config_file(path: &Path) -> Result<UserConfig, ConfigError> {
168 let body = fs::read_to_string(path).map_err(|err| ConfigError::Read {
169 path: path.to_path_buf(),
170 reason: err.to_string(),
171 })?;
172 let parsed = toml::from_str::<RawConfig>(&body).map_err(|err| ConfigError::Parse {
173 path: path.to_path_buf(),
174 reason: err.to_string(),
175 })?;
176 raw_config_to_user_config(parsed, path)
177 }
178
179 pub fn init_config_file(path: Option<PathBuf>, force: bool) -> Result<PathBuf, ConfigError> {
180 let path = match path {
181 Some(path) => expand_user_path(path)?,
182 None => default_config_file(),
183 };
184
185 if path.exists() && !force {
186 return Err(ConfigError::AlreadyExists { path });
187 }
188
189 if let Some(parent) = path.parent() {
190 fs::create_dir_all(parent).map_err(|err| ConfigError::Write {
191 path: parent.to_path_buf(),
192 reason: err.to_string(),
193 })?;
194 }
195
196 fs::write(&path, DEFAULT_CONFIG_TOML).map_err(|err| ConfigError::Write {
197 path: path.clone(),
198 reason: err.to_string(),
199 })?;
200
201 Ok(path)
202 }
203
204 #[derive(Debug, Clone, PartialEq, Eq)]
205 pub struct MicrosoftSetupConfig {
206 pub account_id: String,
207 pub calendar_id: String,
208 pub calendar_name: String,
209 pub sync_past_days: i32,
210 pub sync_future_days: i32,
211 pub redirect_port: u16,
212 }
213
214 #[derive(Debug, Clone, PartialEq, Eq)]
215 pub struct GoogleSetupConfig {
216 pub account_id: String,
217 pub client_id: Option<String>,
218 pub client_secret: Option<String>,
219 pub calendar_id: String,
220 pub calendar_name: String,
221 pub sync_past_days: i32,
222 pub sync_future_days: i32,
223 pub redirect_port: u16,
224 }
225
226 pub fn write_microsoft_setup_config(
227 path: Option<PathBuf>,
228 setup: &MicrosoftSetupConfig,
229 ) -> Result<PathBuf, ConfigError> {
230 let path = match path {
231 Some(path) => expand_user_path(path)?,
232 None => default_config_file(),
233 };
234 let existing = if path.exists() {
235 fs::read_to_string(&path).map_err(|err| ConfigError::Read {
236 path: path.clone(),
237 reason: err.to_string(),
238 })?
239 } else {
240 String::new()
241 };
242 let body = microsoft_setup_config_body(&existing, setup);
243 if let Some(parent) = path.parent() {
244 fs::create_dir_all(parent).map_err(|err| ConfigError::Write {
245 path: parent.to_path_buf(),
246 reason: err.to_string(),
247 })?;
248 }
249 fs::write(&path, body).map_err(|err| ConfigError::Write {
250 path: path.clone(),
251 reason: err.to_string(),
252 })?;
253 Ok(path)
254 }
255
256 pub fn write_google_setup_config(
257 path: Option<PathBuf>,
258 setup: &GoogleSetupConfig,
259 ) -> Result<PathBuf, ConfigError> {
260 let path = match path {
261 Some(path) => expand_user_path(path)?,
262 None => default_config_file(),
263 };
264 let existing = if path.exists() {
265 fs::read_to_string(&path).map_err(|err| ConfigError::Read {
266 path: path.clone(),
267 reason: err.to_string(),
268 })?
269 } else {
270 String::new()
271 };
272 let body = google_setup_config_body(&existing, setup);
273 if let Some(parent) = path.parent() {
274 fs::create_dir_all(parent).map_err(|err| ConfigError::Write {
275 path: parent.to_path_buf(),
276 reason: err.to_string(),
277 })?;
278 }
279 fs::write(&path, body).map_err(|err| ConfigError::Write {
280 path: path.clone(),
281 reason: err.to_string(),
282 })?;
283 Ok(path)
284 }
285
286 fn microsoft_setup_config_body(existing: &str, setup: &MicrosoftSetupConfig) -> String {
287 let mut kept = Vec::new();
288 let mut skipping = false;
289 for line in existing.lines() {
290 if is_toml_table_header(line) {
291 skipping = is_managed_provider_header(line, "microsoft");
292 }
293 if !skipping {
294 kept.push(line);
295 }
296 }
297 while kept.last().is_some_and(|line| line.trim().is_empty()) {
298 kept.pop();
299 }
300
301 let mut body = kept.join("\n");
302 if !body.is_empty() {
303 body.push_str("\n\n");
304 }
305 body.push_str(&format!(
306 concat!(
307 "[providers]\n",
308 "create_target = \"microsoft\"\n\n",
309 "[providers.microsoft]\n",
310 "enabled = true\n",
311 "default_account = {account}\n",
312 "default_calendar = {calendar}\n",
313 "sync_past_days = {sync_past_days}\n",
314 "sync_future_days = {sync_future_days}\n\n",
315 "[[providers.microsoft.accounts]]\n",
316 "id = {account}\n",
317 "redirect_port = {redirect_port}\n",
318 "calendars = [{calendar}]\n",
319 "# Selected calendar: {calendar_name}\n"
320 ),
321 account = toml_string(&setup.account_id),
322 calendar = toml_string(&setup.calendar_id),
323 calendar_name = toml_comment_value(&setup.calendar_name),
324 redirect_port = setup.redirect_port,
325 sync_past_days = setup.sync_past_days.max(0),
326 sync_future_days = setup.sync_future_days.max(1),
327 ));
328 body
329 }
330
331 fn google_setup_config_body(existing: &str, setup: &GoogleSetupConfig) -> String {
332 let mut kept = Vec::new();
333 let mut skipping = false;
334 for line in existing.lines() {
335 if is_toml_table_header(line) {
336 skipping = is_managed_provider_header(line, "google");
337 }
338 if !skipping {
339 kept.push(line);
340 }
341 }
342 while kept.last().is_some_and(|line| line.trim().is_empty()) {
343 kept.pop();
344 }
345
346 let mut body = kept.join("\n");
347 if !body.is_empty() {
348 body.push_str("\n\n");
349 }
350 body.push_str(&format!(
351 concat!(
352 "[providers]\n",
353 "create_target = \"google\"\n\n",
354 "[providers.google]\n",
355 "enabled = true\n",
356 "default_account = {account}\n",
357 "default_calendar = {calendar}\n",
358 "sync_past_days = {sync_past_days}\n",
359 "sync_future_days = {sync_future_days}\n\n",
360 "[[providers.google.accounts]]\n",
361 "id = {account}\n",
362 ),
363 account = toml_string(&setup.account_id),
364 calendar = toml_string(&setup.calendar_id),
365 sync_past_days = setup.sync_past_days.max(0),
366 sync_future_days = setup.sync_future_days.max(1),
367 ));
368 if let Some(client_id) = &setup.client_id {
369 body.push_str(&format!("client_id = {}\n", toml_string(client_id)));
370 }
371 if let Some(client_secret) = &setup.client_secret {
372 body.push_str(&format!("client_secret = {}\n", toml_string(client_secret)));
373 }
374 body.push_str(&format!(
375 concat!(
376 "redirect_port = {redirect_port}\n",
377 "calendars = [{calendar}]\n",
378 "# Selected calendar: {calendar_name}\n"
379 ),
380 calendar = toml_string(&setup.calendar_id),
381 calendar_name = toml_comment_value(&setup.calendar_name),
382 redirect_port = setup.redirect_port,
383 ));
384 body
385 }
386
387 fn is_toml_table_header(line: &str) -> bool {
388 let trimmed = line.trim();
389 trimmed.starts_with('[') && trimmed.ends_with(']')
390 }
391
392 fn is_managed_provider_header(line: &str, provider: &str) -> bool {
393 let trimmed = line.trim();
394 let name = trimmed
395 .trim_start_matches('[')
396 .trim_start_matches('[')
397 .trim_end_matches(']')
398 .trim_end_matches(']')
399 .trim();
400 let provider_header = format!("providers.{provider}");
401 let accounts_header = format!("providers.{provider}.accounts");
402 name == "providers" || name == provider_header || name == accounts_header
403 }
404
405 fn toml_string(value: &str) -> String {
406 let mut quoted = String::from("\"");
407 for character in value.chars() {
408 match character {
409 '\\' => quoted.push_str("\\\\"),
410 '"' => quoted.push_str("\\\""),
411 '\n' => quoted.push_str("\\n"),
412 '\r' => quoted.push_str("\\r"),
413 '\t' => quoted.push_str("\\t"),
414 value if value.is_control() => {
415 quoted.push_str(&format!("\\u{:04x}", u32::from(value)));
416 }
417 value => quoted.push(value),
418 }
419 }
420 quoted.push('"');
421 quoted
422 }
423
424 fn toml_comment_value(value: &str) -> String {
425 value
426 .chars()
427 .map(|character| {
428 if character.is_control() {
429 ' '
430 } else {
431 character
432 }
433 })
434 .collect::<String>()
435 }
436
437 fn raw_config_to_user_config(raw: RawConfig, path: &Path) -> Result<UserConfig, ConfigError> {
438 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
439 let events_file = raw
440 .paths
441 .and_then(|paths| paths.events_file)
442 .map(|path| resolve_config_path(&path, base_dir))
443 .transpose()?;
444 let reminder_state_file = raw
445 .reminders
446 .and_then(|reminders| reminders.state_file)
447 .map(|path| resolve_config_path(&path, base_dir))
448 .transpose()?;
449
450 let (holiday_source, holiday_country) = if let Some(holidays) = raw.holidays {
451 (
452 holidays
453 .source
454 .map(|value| parse_holiday_source(&value, path))
455 .transpose()?,
456 holidays
457 .country
458 .map(|value| parse_holiday_country(&value, path))
459 .transpose()?,
460 )
461 } else {
462 (None, None)
463 };
464
465 let keybindings = if let Some(keybindings) = raw.keybindings {
466 KeyBindings::with_overrides(keybindings.into_overrides()).map_err(|err| {
467 ConfigError::Invalid {
468 path: path.to_path_buf(),
469 reason: format!("invalid keybindings: {err}"),
470 }
471 })?
472 } else {
473 KeyBindings::default()
474 };
475 let providers = raw
476 .providers
477 .map(|providers| providers.into_config(path, base_dir))
478 .transpose()?
479 .unwrap_or_default();
480
481 Ok(UserConfig {
482 path: Some(path.to_path_buf()),
483 events_file,
484 holiday_source,
485 holiday_country,
486 reminder_state_file,
487 keybindings,
488 providers,
489 })
490 }
491
492 fn parse_holiday_source(value: &str, path: &Path) -> Result<ConfigHolidaySource, ConfigError> {
493 match value {
494 "off" => Ok(ConfigHolidaySource::Off),
495 "us-federal" => Ok(ConfigHolidaySource::UsFederal),
496 "nager" => Ok(ConfigHolidaySource::Nager),
497 _ => Err(ConfigError::Invalid {
498 path: path.to_path_buf(),
499 reason: format!(
500 "invalid holidays.source '{value}'; expected off, us-federal, or nager"
501 ),
502 }),
503 }
504 }
505
506 fn parse_holiday_country(value: &str, path: &Path) -> Result<String, ConfigError> {
507 if value.len() == 2 && value.bytes().all(|value| value.is_ascii_alphabetic()) {
508 Ok(value.to_ascii_uppercase())
509 } else {
510 Err(ConfigError::Invalid {
511 path: path.to_path_buf(),
512 reason: format!("invalid holidays.country '{value}'; expected two ASCII letters"),
513 })
514 }
515 }
516
517 fn resolve_config_path(value: &str, base_dir: &Path) -> Result<PathBuf, ConfigError> {
518 let expanded = expand_user_path(PathBuf::from(value))?;
519 if expanded.is_absolute() {
520 Ok(expanded)
521 } else {
522 Ok(base_dir.join(expanded))
523 }
524 }
525
526 pub fn expand_user_path(path: PathBuf) -> Result<PathBuf, ConfigError> {
527 let Some(value) = path.to_str() else {
528 return Ok(path);
529 };
530
531 if value == "~" {
532 return Ok(home_dir()?.to_path_buf());
533 }
534
535 if let Some(rest) = value.strip_prefix("~/") {
536 return Ok(home_dir()?.join(rest));
537 }
538
539 Ok(path)
540 }
541
542 fn home_dir() -> Result<PathBuf, ConfigError> {
543 env::var_os("HOME")
544 .map(PathBuf::from)
545 .ok_or(ConfigError::MissingHome)
546 }
547
548 #[derive(Debug, Deserialize)]
549 #[serde(deny_unknown_fields)]
550 struct RawConfig {
551 paths: Option<RawPathsConfig>,
552 holidays: Option<RawHolidaysConfig>,
553 reminders: Option<RawRemindersConfig>,
554 providers: Option<RawProvidersConfig>,
555 keybindings: Option<RawKeyBindingsConfig>,
556 }
557
558 #[derive(Debug, Deserialize)]
559 #[serde(deny_unknown_fields)]
560 struct RawPathsConfig {
561 events_file: Option<String>,
562 }
563
564 #[derive(Debug, Deserialize)]
565 #[serde(deny_unknown_fields)]
566 struct RawHolidaysConfig {
567 source: Option<String>,
568 country: Option<String>,
569 }
570
571 #[derive(Debug, Deserialize)]
572 #[serde(deny_unknown_fields)]
573 struct RawRemindersConfig {
574 state_file: Option<String>,
575 }
576
577 #[derive(Debug, Default, Deserialize)]
578 #[serde(deny_unknown_fields)]
579 struct RawProvidersConfig {
580 create_target: Option<String>,
581 microsoft: Option<RawMicrosoftProviderConfig>,
582 google: Option<RawGoogleProviderConfig>,
583 }
584
585 #[derive(Debug, Default, Deserialize)]
586 #[serde(deny_unknown_fields)]
587 struct RawMicrosoftProviderConfig {
588 enabled: Option<bool>,
589 default_account: Option<String>,
590 default_calendar: Option<String>,
591 sync_past_days: Option<i32>,
592 sync_future_days: Option<i32>,
593 cache_file: Option<String>,
594 accounts: Option<Vec<RawMicrosoftAccountConfig>>,
595 }
596
597 #[derive(Debug, Deserialize)]
598 #[serde(deny_unknown_fields)]
599 struct RawMicrosoftAccountConfig {
600 id: String,
601 client_id: Option<String>,
602 tenant: Option<String>,
603 redirect_port: Option<u16>,
604 calendars: Option<Vec<String>>,
605 }
606
607 #[derive(Debug, Default, Deserialize)]
608 #[serde(deny_unknown_fields)]
609 struct RawGoogleProviderConfig {
610 enabled: Option<bool>,
611 default_account: Option<String>,
612 default_calendar: Option<String>,
613 sync_past_days: Option<i32>,
614 sync_future_days: Option<i32>,
615 cache_file: Option<String>,
616 accounts: Option<Vec<RawGoogleAccountConfig>>,
617 }
618
619 #[derive(Debug, Deserialize)]
620 #[serde(deny_unknown_fields)]
621 struct RawGoogleAccountConfig {
622 id: String,
623 client_id: Option<String>,
624 client_secret: Option<String>,
625 redirect_port: Option<u16>,
626 calendars: Option<Vec<String>>,
627 }
628
629 #[derive(Debug, Default, Deserialize)]
630 #[serde(deny_unknown_fields)]
631 struct RawKeyBindingsConfig {
632 move_left: Option<Vec<String>>,
633 move_right: Option<Vec<String>>,
634 move_up: Option<Vec<String>>,
635 move_down: Option<Vec<String>>,
636 open_day_or_edit: Option<Vec<String>>,
637 close_day: Option<Vec<String>>,
638 create_event: Option<Vec<String>>,
639 delete_event: Option<Vec<String>>,
640 copy_event: Option<Vec<String>>,
641 help: Option<Vec<String>>,
642 quit: Option<Vec<String>>,
643 jump_monday: Option<Vec<String>>,
644 jump_tuesday: Option<Vec<String>>,
645 jump_wednesday: Option<Vec<String>>,
646 jump_thursday: Option<Vec<String>>,
647 jump_friday: Option<Vec<String>>,
648 jump_saturday: Option<Vec<String>>,
649 jump_sunday: Option<Vec<String>>,
650 }
651
652 impl RawKeyBindingsConfig {
653 fn into_overrides(self) -> KeyBindingOverrides {
654 KeyBindingOverrides {
655 move_left: self.move_left,
656 move_right: self.move_right,
657 move_up: self.move_up,
658 move_down: self.move_down,
659 open_day_or_edit: self.open_day_or_edit,
660 close_day: self.close_day,
661 create_event: self.create_event,
662 delete_event: self.delete_event,
663 copy_event: self.copy_event,
664 help: self.help,
665 quit: self.quit,
666 jump_monday: self.jump_monday,
667 jump_tuesday: self.jump_tuesday,
668 jump_wednesday: self.jump_wednesday,
669 jump_thursday: self.jump_thursday,
670 jump_friday: self.jump_friday,
671 jump_saturday: self.jump_saturday,
672 jump_sunday: self.jump_sunday,
673 }
674 }
675 }
676
677 impl RawProvidersConfig {
678 fn into_config(self, path: &Path, base_dir: &Path) -> Result<ProviderConfig, ConfigError> {
679 let mut config = ProviderConfig::default();
680 let create_target_was_set = self.create_target.is_some();
681 if let Some(create_target) = self.create_target {
682 config.create_target = parse_create_target(&create_target, path)?;
683 }
684 if let Some(microsoft) = self.microsoft {
685 config.microsoft = microsoft.into_config(path, base_dir)?;
686 if config.microsoft.enabled && !create_target_was_set {
687 config.create_target = ProviderCreateTarget::Microsoft;
688 }
689 }
690 if let Some(google) = self.google {
691 config.google = google.into_config(path, base_dir)?;
692 if config.google.enabled && !create_target_was_set {
693 config.create_target = ProviderCreateTarget::Google;
694 }
695 }
696 config
697 .microsoft
698 .validate()
699 .map_err(|err| ConfigError::Invalid {
700 path: path.to_path_buf(),
701 reason: err.to_string(),
702 })?;
703 config
704 .google
705 .validate()
706 .map_err(|err| ConfigError::Invalid {
707 path: path.to_path_buf(),
708 reason: err.to_string(),
709 })?;
710 Ok(config)
711 }
712 }
713
714 impl RawMicrosoftProviderConfig {
715 fn into_config(
716 self,
717 path: &Path,
718 base_dir: &Path,
719 ) -> Result<MicrosoftProviderConfig, ConfigError> {
720 let mut config = MicrosoftProviderConfig::default();
721 if let Some(enabled) = self.enabled {
722 config.enabled = enabled;
723 }
724 config.default_account = self.default_account;
725 config.default_calendar = self.default_calendar;
726 if let Some(sync_past_days) = self.sync_past_days {
727 config.sync_past_days = sync_past_days.max(0);
728 }
729 if let Some(sync_future_days) = self.sync_future_days {
730 config.sync_future_days = sync_future_days.max(1);
731 }
732 if let Some(cache_file) = self.cache_file {
733 config.cache_file = resolve_config_path(&cache_file, base_dir)?;
734 }
735 config.accounts = self
736 .accounts
737 .unwrap_or_default()
738 .into_iter()
739 .map(RawMicrosoftAccountConfig::into_config)
740 .collect();
741 config.validate().map_err(|err| ConfigError::Invalid {
742 path: path.to_path_buf(),
743 reason: err.to_string(),
744 })?;
745 Ok(config)
746 }
747 }
748
749 impl RawMicrosoftAccountConfig {
750 fn into_config(self) -> MicrosoftAccountConfig {
751 MicrosoftAccountConfig {
752 id: self.id,
753 client_id: self
754 .client_id
755 .unwrap_or_else(|| MICROSOFT_OFFICIAL_CLIENT_ID.to_string()),
756 tenant: self
757 .tenant
758 .unwrap_or_else(|| MICROSOFT_DEFAULT_TENANT.to_string()),
759 redirect_port: self.redirect_port.unwrap_or(8765),
760 calendars: self.calendars.unwrap_or_default(),
761 }
762 }
763 }
764
765 impl RawGoogleProviderConfig {
766 fn into_config(
767 self,
768 path: &Path,
769 base_dir: &Path,
770 ) -> Result<GoogleProviderConfig, ConfigError> {
771 let mut config = GoogleProviderConfig::default();
772 if let Some(enabled) = self.enabled {
773 config.enabled = enabled;
774 }
775 config.default_account = self.default_account;
776 config.default_calendar = self.default_calendar;
777 if let Some(sync_past_days) = self.sync_past_days {
778 config.sync_past_days = sync_past_days.max(0);
779 }
780 if let Some(sync_future_days) = self.sync_future_days {
781 config.sync_future_days = sync_future_days.max(1);
782 }
783 if let Some(cache_file) = self.cache_file {
784 config.cache_file = resolve_config_path(&cache_file, base_dir)?;
785 }
786 config.accounts = self
787 .accounts
788 .unwrap_or_default()
789 .into_iter()
790 .map(RawGoogleAccountConfig::into_config)
791 .collect();
792 config.validate().map_err(|err| ConfigError::Invalid {
793 path: path.to_path_buf(),
794 reason: err.to_string(),
795 })?;
796 Ok(config)
797 }
798 }
799
800 impl RawGoogleAccountConfig {
801 fn into_config(self) -> GoogleAccountConfig {
802 let uses_official_client = self.client_id.is_none();
803 let official_client = uses_official_client
804 .then(google_official_client_config)
805 .flatten();
806 let official_client_id = official_client
807 .as_ref()
808 .map(|(client_id, _)| client_id.clone());
809 let official_client_secret = official_client.and_then(|(_, client_secret)| client_secret);
810 GoogleAccountConfig {
811 id: self.id,
812 client_id: self.client_id.or(official_client_id).unwrap_or_default(),
813 client_secret: self.client_secret.or(official_client_secret),
814 redirect_port: self.redirect_port.unwrap_or(8766),
815 calendars: self.calendars.unwrap_or_default(),
816 }
817 }
818 }
819
820 fn parse_create_target(value: &str, path: &Path) -> Result<ProviderCreateTarget, ConfigError> {
821 match value {
822 "local" => Ok(ProviderCreateTarget::Local),
823 "microsoft" => Ok(ProviderCreateTarget::Microsoft),
824 "google" => Ok(ProviderCreateTarget::Google),
825 _ => Err(ConfigError::Invalid {
826 path: path.to_path_buf(),
827 reason: format!(
828 "invalid providers.create_target '{value}'; expected local, microsoft, or google"
829 ),
830 }),
831 }
832 }
833
834 #[derive(Debug, Clone, PartialEq, Eq)]
835 pub enum ConfigError {
836 Missing { path: PathBuf },
837 MissingHome,
838 AlreadyExists { path: PathBuf },
839 Read { path: PathBuf, reason: String },
840 Write { path: PathBuf, reason: String },
841 Parse { path: PathBuf, reason: String },
842 Invalid { path: PathBuf, reason: String },
843 }
844
845 impl fmt::Display for ConfigError {
846 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
847 match self {
848 Self::Missing { path } => write!(f, "config file does not exist: {}", path.display()),
849 Self::MissingHome => write!(f, "failed to locate a user home directory"),
850 Self::AlreadyExists { path } => {
851 write!(
852 f,
853 "config file already exists at {}; pass --force to overwrite",
854 path.display()
855 )
856 }
857 Self::Read { path, reason } => {
858 write!(f, "failed to read config {}: {reason}", path.display())
859 }
860 Self::Write { path, reason } => {
861 write!(f, "failed to write config {}: {reason}", path.display())
862 }
863 Self::Parse { path, reason } => {
864 write!(f, "failed to parse config {}: {reason}", path.display())
865 }
866 Self::Invalid { path, reason } => {
867 write!(f, "invalid config {}: {reason}", path.display())
868 }
869 }
870 }
871 }
872
873 impl Error for ConfigError {}
874
875 impl From<KeyBindingError> for ConfigError {
876 fn from(err: KeyBindingError) -> Self {
877 Self::Invalid {
878 path: PathBuf::from("<config>"),
879 reason: err.to_string(),
880 }
881 }
882 }
883
884 #[cfg(test)]
885 mod tests {
886 use super::*;
887 use std::sync::atomic::{AtomicUsize, Ordering};
888
889 static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
890
891 fn temp_config_path(name: &str) -> PathBuf {
892 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
893 env::temp_dir()
894 .join(format!("rcal-config-test-{}-{counter}", std::process::id()))
895 .join(name)
896 }
897
898 #[test]
899 fn default_config_path_prefers_xdg_then_home() {
900 assert_eq!(
901 default_config_file_for(Some("/tmp/xdg"), Some("/tmp/home")),
902 PathBuf::from("/tmp/xdg/rcal/config.toml")
903 );
904 assert_eq!(
905 default_config_file_for(None::<&str>, Some("/tmp/home")),
906 PathBuf::from("/tmp/home/.config/rcal/config.toml")
907 );
908 }
909
910 #[test]
911 fn missing_discovered_config_is_empty() {
912 let config = UserConfig::empty();
913 assert!(config.events_file.is_none());
914 assert!(config.reminder_state_file.is_none());
915 }
916
917 #[test]
918 fn config_loads_paths_relative_to_file_and_expands_home() {
919 let path = temp_config_path("relative/config.toml");
920 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
921 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
922 fs::write(
923 &path,
924 r#"
925 [paths]
926 events_file = "events.json"
927
928 [reminders]
929 state_file = "~/state.json"
930 "#,
931 )
932 .expect("config writes");
933
934 let config = load_config_file(&path).expect("config loads");
935 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
936
937 let expected_events_file = path.parent().expect("config dir").join("events.json");
938 assert_eq!(
939 config.events_file.as_deref(),
940 Some(expected_events_file.as_path())
941 );
942 assert!(
943 config
944 .reminder_state_file
945 .expect("state path")
946 .ends_with("state.json")
947 );
948 }
949
950 #[test]
951 fn generated_config_parses_cleanly() {
952 let parsed = toml::from_str::<RawConfig>(DEFAULT_CONFIG_TOML).expect("template parses");
953 assert!(parsed.paths.expect("paths").events_file.is_some());
954 assert!(parsed.keybindings.expect("keybindings").quit.is_some());
955 }
956
957 #[test]
958 fn microsoft_provider_config_parses_and_resolves_paths() {
959 let path = temp_config_path("providers/config.toml");
960 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
961 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
962 fs::write(
963 &path,
964 r#"
965 [providers]
966 create_target = "microsoft"
967
968 [providers.microsoft]
969 enabled = true
970 default_account = "work"
971 default_calendar = "cal-1"
972 sync_past_days = 7
973 sync_future_days = 90
974 cache_file = "microsoft-cache.json"
975
976 [[providers.microsoft.accounts]]
977 id = "work"
978 client_id = "client-id"
979 tenant = "organizations"
980 redirect_port = 9001
981 calendars = ["cal-1"]
982 "#,
983 )
984 .expect("config writes");
985
986 let config = load_config_file(&path).expect("config loads");
987 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
988
989 assert_eq!(
990 config.providers.create_target,
991 ProviderCreateTarget::Microsoft
992 );
993 assert!(config.providers.microsoft.enabled);
994 assert_eq!(
995 config.providers.microsoft.cache_file,
996 path.parent()
997 .expect("config dir")
998 .join("microsoft-cache.json")
999 );
1000 assert_eq!(config.providers.microsoft.sync_past_days, 7);
1001 assert_eq!(config.providers.microsoft.sync_future_days, 90);
1002 assert_eq!(config.providers.microsoft.accounts[0].redirect_port, 9001);
1003 }
1004
1005 #[test]
1006 fn microsoft_provider_defaults_to_official_public_client() {
1007 let path = temp_config_path("providers-official/config.toml");
1008 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1009 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1010 fs::write(
1011 &path,
1012 r#"
1013 [providers.microsoft]
1014 enabled = true
1015 default_account = "work"
1016 default_calendar = "cal-1"
1017
1018 [[providers.microsoft.accounts]]
1019 id = "work"
1020 calendars = ["cal-1"]
1021 "#,
1022 )
1023 .expect("config writes");
1024
1025 let config = load_config_file(&path).expect("config loads");
1026 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1027
1028 let account = &config.providers.microsoft.accounts[0];
1029 assert_eq!(account.client_id, MICROSOFT_OFFICIAL_CLIENT_ID);
1030 assert_eq!(account.tenant, MICROSOFT_DEFAULT_TENANT);
1031 }
1032
1033 #[test]
1034 fn google_provider_defaults_to_official_desktop_client() {
1035 let path = temp_config_path("google-providers-official/config.toml");
1036 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1037 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1038 fs::write(
1039 &path,
1040 r#"
1041 [providers.google]
1042 enabled = true
1043 default_account = "personal"
1044 default_calendar = "primary"
1045
1046 [[providers.google.accounts]]
1047 id = "personal"
1048 calendars = ["primary"]
1049 "#,
1050 )
1051 .expect("config writes");
1052
1053 let config = load_config_file(&path).expect("config loads");
1054 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1055
1056 let account = &config.providers.google.accounts[0];
1057 let (official_client_id, official_client_secret) =
1058 google_official_client_config().expect("official Google client exists");
1059 assert_eq!(account.client_id, official_client_id);
1060 assert_eq!(account.client_secret, official_client_secret);
1061 }
1062
1063 #[test]
1064 fn google_provider_config_parses_and_resolves_paths() {
1065 let path = temp_config_path("google-providers/config.toml");
1066 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1067 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1068 fs::write(
1069 &path,
1070 r#"
1071 [providers]
1072 create_target = "google"
1073
1074 [providers.google]
1075 enabled = true
1076 default_account = "personal"
1077 default_calendar = "primary"
1078 sync_past_days = 14
1079 sync_future_days = 120
1080 cache_file = "google-cache.json"
1081
1082 [[providers.google.accounts]]
1083 id = "personal"
1084 client_id = "google-client"
1085 client_secret = "google-secret"
1086 redirect_port = 9002
1087 calendars = ["primary"]
1088 "#,
1089 )
1090 .expect("config writes");
1091
1092 let config = load_config_file(&path).expect("config loads");
1093 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1094
1095 assert_eq!(config.providers.create_target, ProviderCreateTarget::Google);
1096 assert!(config.providers.google.enabled);
1097 assert_eq!(
1098 config.providers.google.cache_file,
1099 path.parent().expect("config dir").join("google-cache.json")
1100 );
1101 assert_eq!(config.providers.google.sync_past_days, 14);
1102 assert_eq!(config.providers.google.sync_future_days, 120);
1103 assert_eq!(
1104 config.providers.google.accounts[0].client_id,
1105 "google-client"
1106 );
1107 assert_eq!(
1108 config.providers.google.accounts[0].client_secret.as_deref(),
1109 Some("google-secret")
1110 );
1111 assert_eq!(config.providers.google.accounts[0].redirect_port, 9002);
1112 }
1113
1114 #[test]
1115 fn microsoft_setup_config_preserves_unrelated_sections() {
1116 let path = temp_config_path("providers-setup/config.toml");
1117 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1118 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1119 fs::write(
1120 &path,
1121 r#"
1122 [paths]
1123 events_file = "./events.json"
1124
1125 [providers]
1126 create_target = "local"
1127
1128 [providers.microsoft]
1129 enabled = false
1130
1131 [[providers.microsoft.accounts]]
1132 id = "old"
1133 calendars = []
1134
1135 [providers.google]
1136 enabled = true
1137 default_account = "personal"
1138
1139 [[providers.google.accounts]]
1140 id = "personal"
1141 client_id = "google-client"
1142 calendars = ["primary"]
1143
1144 [keybindings]
1145 quit = ["q"]
1146 "#,
1147 )
1148 .expect("config writes");
1149
1150 write_microsoft_setup_config(
1151 Some(path.clone()),
1152 &MicrosoftSetupConfig {
1153 account_id: "work".to_string(),
1154 calendar_id: "cal-1".to_string(),
1155 calendar_name: "Calendar\nInjected".to_string(),
1156 sync_past_days: 7,
1157 sync_future_days: 90,
1158 redirect_port: 9001,
1159 },
1160 )
1161 .expect("setup config writes");
1162 let body = fs::read_to_string(&path).expect("config reads");
1163 let config = load_config_file(&path).expect("config loads");
1164 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1165
1166 assert!(body.contains("[paths]"));
1167 assert!(body.contains("[keybindings]"));
1168 assert!(body.contains("[providers.google]"));
1169 assert!(body.contains("# Selected calendar: Calendar Injected"));
1170 assert!(!body.contains("id = \"old\""));
1171 assert_eq!(
1172 config.providers.create_target,
1173 ProviderCreateTarget::Microsoft
1174 );
1175 assert_eq!(
1176 config.providers.microsoft.default_calendar.as_deref(),
1177 Some("cal-1")
1178 );
1179 assert_eq!(config.providers.microsoft.accounts[0].redirect_port, 9001);
1180 assert!(config.providers.google.enabled);
1181 assert_eq!(
1182 config.providers.google.accounts[0].client_id,
1183 "google-client"
1184 );
1185 }
1186
1187 #[test]
1188 fn google_setup_config_preserves_microsoft_provider() {
1189 let path = temp_config_path("google-setup/config.toml");
1190 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1191 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1192 fs::write(
1193 &path,
1194 r#"
1195 [providers]
1196 create_target = "microsoft"
1197
1198 [providers.microsoft]
1199 enabled = true
1200 default_account = "work"
1201 default_calendar = "cal-1"
1202
1203 [[providers.microsoft.accounts]]
1204 id = "work"
1205 calendars = ["cal-1"]
1206
1207 [providers.google]
1208 enabled = false
1209 "#,
1210 )
1211 .expect("config writes");
1212
1213 write_google_setup_config(
1214 Some(path.clone()),
1215 &GoogleSetupConfig {
1216 account_id: "personal".to_string(),
1217 client_id: Some("google-client".to_string()),
1218 client_secret: Some("google-secret".to_string()),
1219 calendar_id: "primary".to_string(),
1220 calendar_name: "Calendar".to_string(),
1221 sync_past_days: 7,
1222 sync_future_days: 90,
1223 redirect_port: 9002,
1224 },
1225 )
1226 .expect("setup config writes");
1227 let body = fs::read_to_string(&path).expect("config reads");
1228 let config = load_config_file(&path).expect("config loads");
1229 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1230
1231 assert!(body.contains("[providers.microsoft]"));
1232 assert!(body.contains("[providers.google]"));
1233 assert_eq!(config.providers.create_target, ProviderCreateTarget::Google);
1234 assert!(config.providers.microsoft.enabled);
1235 assert_eq!(
1236 config.providers.google.default_calendar.as_deref(),
1237 Some("primary")
1238 );
1239 assert_eq!(
1240 config.providers.google.accounts[0].client_secret.as_deref(),
1241 Some("google-secret")
1242 );
1243 }
1244
1245 #[test]
1246 fn invalid_microsoft_provider_config_fails_clearly() {
1247 let path = temp_config_path("providers-invalid/config.toml");
1248 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1249 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1250 fs::write(
1251 &path,
1252 r#"
1253 [providers.microsoft]
1254 enabled = true
1255 default_account = "work"
1256 default_calendar = "missing"
1257
1258 [[providers.microsoft.accounts]]
1259 id = "work"
1260 client_id = "client-id"
1261 tenant = "organizations"
1262 calendars = ["cal-1"]
1263 "#,
1264 )
1265 .expect("config writes");
1266
1267 let err = load_config_file(&path).expect_err("invalid provider config fails");
1268 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1269
1270 assert!(err.to_string().contains("default calendar"));
1271 }
1272
1273 #[test]
1274 fn malformed_and_unknown_config_fail_clearly() {
1275 assert!(toml::from_str::<RawConfig>("not = [").is_err());
1276 assert!(toml::from_str::<RawConfig>("[unknown]\nvalue = true\n").is_err());
1277 }
1278
1279 #[test]
1280 fn config_init_refuses_to_overwrite_without_force() {
1281 let path = temp_config_path("init/config.toml");
1282 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1283 let created = init_config_file(Some(path.clone()), false).expect("config initializes");
1284 assert_eq!(created, path);
1285 let err = init_config_file(Some(path.clone()), false).expect_err("overwrite fails");
1286 init_config_file(Some(path.clone()), true).expect("force overwrites");
1287 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1288 assert!(matches!(err, ConfigError::AlreadyExists { .. }));
1289 }
1290
1291 #[test]
1292 fn invalid_keybindings_fail() {
1293 let path = temp_config_path("keys/config.toml");
1294 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1295 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1296 fs::write(
1297 &path,
1298 r#"
1299 [keybindings]
1300 create_event = ["1"]
1301 "#,
1302 )
1303 .expect("config writes");
1304
1305 let err = load_config_file(&path).expect_err("digit key fails");
1306 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
1307 assert!(err.to_string().contains("reserved for day jumps"));
1308 }
1309 }
1310