Rust · 29377 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 MICROSOFT_DEFAULT_TENANT, MICROSOFT_OFFICIAL_CLIENT_ID, MicrosoftAccountConfig,
14 MicrosoftProviderConfig, ProviderConfig, ProviderCreateTarget,
15 },
16 };
17
18 pub const DEFAULT_CONFIG_TOML: &str = r#"# rcal configuration
19 # Generate this file with `rcal config init`.
20
21 [paths]
22 # Local user-created events.
23 events_file = "~/.local/share/rcal/events.json"
24
25 [holidays]
26 # One of: "off", "us-federal", "nager".
27 source = "us-federal"
28 # Two-letter country code. Only used by the Nager.Date holiday source.
29 country = "US"
30
31 [reminders]
32 # Delivered/skipped reminder state. Services snapshot this path at install time.
33 state_file = "~/.local/state/rcal/reminders-state.json"
34
35 [providers]
36 # Where newly created events go when provider support is enabled: "local" or "microsoft".
37 create_target = "local"
38
39 [providers.microsoft]
40 # Microsoft Graph provider for Outlook / Microsoft 365 calendars.
41 # rcal ships an official public/native Microsoft client ID.
42 # Advanced users may override client_id and tenant inside an account block.
43 enabled = false
44 default_account = "work"
45 sync_past_days = 30
46 sync_future_days = 365
47 # Provider cache is separate from the local events file.
48 # cache_file = "~/.cache/rcal/microsoft-cache.json"
49
50 [[providers.microsoft.accounts]]
51 id = "work"
52 redirect_port = 8765
53 calendars = []
54
55 [keybindings]
56 # Normal month/day app commands. Modal/form editing keys are fixed for now.
57 move_left = ["left"]
58 move_right = ["right"]
59 move_up = ["up"]
60 move_down = ["down"]
61 open_day_or_edit = ["enter"]
62 close_day = ["esc"]
63 create_event = ["+"]
64 delete_event = ["d"]
65 copy_event = ["c"]
66 help = ["?"]
67 quit = ["q", "ctrl-c"]
68
69 jump_monday = ["m"]
70 jump_tuesday = ["tu"]
71 jump_wednesday = ["w"]
72 jump_thursday = ["th"]
73 jump_friday = ["f"]
74 jump_saturday = ["sa"]
75 jump_sunday = ["su"]
76 "#;
77
78 #[derive(Debug, Clone, PartialEq, Eq)]
79 pub struct UserConfig {
80 pub path: Option<PathBuf>,
81 pub events_file: Option<PathBuf>,
82 pub holiday_source: Option<ConfigHolidaySource>,
83 pub holiday_country: Option<String>,
84 pub reminder_state_file: Option<PathBuf>,
85 pub keybindings: KeyBindings,
86 pub providers: ProviderConfig,
87 }
88
89 impl UserConfig {
90 pub fn empty() -> Self {
91 Self {
92 path: None,
93 events_file: None,
94 holiday_source: None,
95 holiday_country: None,
96 reminder_state_file: None,
97 keybindings: KeyBindings::default(),
98 providers: ProviderConfig::default(),
99 }
100 }
101 }
102
103 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
104 pub enum ConfigHolidaySource {
105 Off,
106 UsFederal,
107 Nager,
108 }
109
110 pub fn default_config_file() -> PathBuf {
111 default_config_file_for(env::var_os("XDG_CONFIG_HOME"), env::var_os("HOME"))
112 }
113
114 fn default_config_file_for(
115 xdg_config_home: Option<impl Into<PathBuf>>,
116 home: Option<impl Into<PathBuf>>,
117 ) -> PathBuf {
118 if let Some(xdg_config_home) = xdg_config_home {
119 return xdg_config_home.into().join("rcal").join("config.toml");
120 }
121
122 if let Some(home) = home {
123 return home.into().join(".config").join("rcal").join("config.toml");
124 }
125
126 env::temp_dir().join("rcal").join("config.toml")
127 }
128
129 pub fn load_discovered_config() -> Result<UserConfig, ConfigError> {
130 let path = default_config_file();
131 if !path.exists() {
132 return Ok(UserConfig::empty());
133 }
134
135 load_config_file(&path)
136 }
137
138 pub fn load_explicit_config(path: impl Into<PathBuf>) -> Result<UserConfig, ConfigError> {
139 let path = expand_user_path(path.into())?;
140 if !path.exists() {
141 return Err(ConfigError::Missing { path });
142 }
143
144 load_config_file(&path)
145 }
146
147 pub fn load_config_file(path: &Path) -> Result<UserConfig, ConfigError> {
148 let body = fs::read_to_string(path).map_err(|err| ConfigError::Read {
149 path: path.to_path_buf(),
150 reason: err.to_string(),
151 })?;
152 let parsed = toml::from_str::<RawConfig>(&body).map_err(|err| ConfigError::Parse {
153 path: path.to_path_buf(),
154 reason: err.to_string(),
155 })?;
156 raw_config_to_user_config(parsed, path)
157 }
158
159 pub fn init_config_file(path: Option<PathBuf>, force: bool) -> Result<PathBuf, ConfigError> {
160 let path = match path {
161 Some(path) => expand_user_path(path)?,
162 None => default_config_file(),
163 };
164
165 if path.exists() && !force {
166 return Err(ConfigError::AlreadyExists { path });
167 }
168
169 if let Some(parent) = path.parent() {
170 fs::create_dir_all(parent).map_err(|err| ConfigError::Write {
171 path: parent.to_path_buf(),
172 reason: err.to_string(),
173 })?;
174 }
175
176 fs::write(&path, DEFAULT_CONFIG_TOML).map_err(|err| ConfigError::Write {
177 path: path.clone(),
178 reason: err.to_string(),
179 })?;
180
181 Ok(path)
182 }
183
184 #[derive(Debug, Clone, PartialEq, Eq)]
185 pub struct MicrosoftSetupConfig {
186 pub account_id: String,
187 pub calendar_id: String,
188 pub calendar_name: String,
189 pub sync_past_days: i32,
190 pub sync_future_days: i32,
191 pub redirect_port: u16,
192 }
193
194 pub fn write_microsoft_setup_config(
195 path: Option<PathBuf>,
196 setup: &MicrosoftSetupConfig,
197 ) -> Result<PathBuf, ConfigError> {
198 let path = match path {
199 Some(path) => expand_user_path(path)?,
200 None => default_config_file(),
201 };
202 let existing = if path.exists() {
203 fs::read_to_string(&path).map_err(|err| ConfigError::Read {
204 path: path.clone(),
205 reason: err.to_string(),
206 })?
207 } else {
208 String::new()
209 };
210 let body = microsoft_setup_config_body(&existing, setup);
211 if let Some(parent) = path.parent() {
212 fs::create_dir_all(parent).map_err(|err| ConfigError::Write {
213 path: parent.to_path_buf(),
214 reason: err.to_string(),
215 })?;
216 }
217 fs::write(&path, body).map_err(|err| ConfigError::Write {
218 path: path.clone(),
219 reason: err.to_string(),
220 })?;
221 Ok(path)
222 }
223
224 fn microsoft_setup_config_body(existing: &str, setup: &MicrosoftSetupConfig) -> String {
225 let mut kept = Vec::new();
226 let mut skipping = false;
227 for line in existing.lines() {
228 if is_toml_table_header(line) {
229 skipping = is_managed_provider_header(line);
230 }
231 if !skipping {
232 kept.push(line);
233 }
234 }
235 while kept.last().is_some_and(|line| line.trim().is_empty()) {
236 kept.pop();
237 }
238
239 let mut body = kept.join("\n");
240 if !body.is_empty() {
241 body.push_str("\n\n");
242 }
243 body.push_str(&format!(
244 concat!(
245 "[providers]\n",
246 "create_target = \"microsoft\"\n\n",
247 "[providers.microsoft]\n",
248 "enabled = true\n",
249 "default_account = {account}\n",
250 "default_calendar = {calendar}\n",
251 "sync_past_days = {sync_past_days}\n",
252 "sync_future_days = {sync_future_days}\n\n",
253 "[[providers.microsoft.accounts]]\n",
254 "id = {account}\n",
255 "redirect_port = {redirect_port}\n",
256 "calendars = [{calendar}]\n",
257 "# Selected calendar: {calendar_name}\n"
258 ),
259 account = toml_string(&setup.account_id),
260 calendar = toml_string(&setup.calendar_id),
261 calendar_name = toml_comment_value(&setup.calendar_name),
262 redirect_port = setup.redirect_port,
263 sync_past_days = setup.sync_past_days.max(0),
264 sync_future_days = setup.sync_future_days.max(1),
265 ));
266 body
267 }
268
269 fn is_toml_table_header(line: &str) -> bool {
270 let trimmed = line.trim();
271 trimmed.starts_with('[') && trimmed.ends_with(']')
272 }
273
274 fn is_managed_provider_header(line: &str) -> bool {
275 let trimmed = line.trim();
276 let name = trimmed
277 .trim_start_matches('[')
278 .trim_start_matches('[')
279 .trim_end_matches(']')
280 .trim_end_matches(']')
281 .trim();
282 name == "providers" || name == "providers.microsoft" || name == "providers.microsoft.accounts"
283 }
284
285 fn toml_string(value: &str) -> String {
286 let mut quoted = String::from("\"");
287 for character in value.chars() {
288 match character {
289 '\\' => quoted.push_str("\\\\"),
290 '"' => quoted.push_str("\\\""),
291 '\n' => quoted.push_str("\\n"),
292 '\r' => quoted.push_str("\\r"),
293 '\t' => quoted.push_str("\\t"),
294 value if value.is_control() => {
295 quoted.push_str(&format!("\\u{:04x}", u32::from(value)));
296 }
297 value => quoted.push(value),
298 }
299 }
300 quoted.push('"');
301 quoted
302 }
303
304 fn toml_comment_value(value: &str) -> String {
305 value
306 .chars()
307 .map(|character| {
308 if character.is_control() {
309 ' '
310 } else {
311 character
312 }
313 })
314 .collect::<String>()
315 }
316
317 fn raw_config_to_user_config(raw: RawConfig, path: &Path) -> Result<UserConfig, ConfigError> {
318 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
319 let events_file = raw
320 .paths
321 .and_then(|paths| paths.events_file)
322 .map(|path| resolve_config_path(&path, base_dir))
323 .transpose()?;
324 let reminder_state_file = raw
325 .reminders
326 .and_then(|reminders| reminders.state_file)
327 .map(|path| resolve_config_path(&path, base_dir))
328 .transpose()?;
329
330 let (holiday_source, holiday_country) = if let Some(holidays) = raw.holidays {
331 (
332 holidays
333 .source
334 .map(|value| parse_holiday_source(&value, path))
335 .transpose()?,
336 holidays
337 .country
338 .map(|value| parse_holiday_country(&value, path))
339 .transpose()?,
340 )
341 } else {
342 (None, None)
343 };
344
345 let keybindings = if let Some(keybindings) = raw.keybindings {
346 KeyBindings::with_overrides(keybindings.into_overrides()).map_err(|err| {
347 ConfigError::Invalid {
348 path: path.to_path_buf(),
349 reason: format!("invalid keybindings: {err}"),
350 }
351 })?
352 } else {
353 KeyBindings::default()
354 };
355 let providers = raw
356 .providers
357 .map(|providers| providers.into_config(path, base_dir))
358 .transpose()?
359 .unwrap_or_default();
360
361 Ok(UserConfig {
362 path: Some(path.to_path_buf()),
363 events_file,
364 holiday_source,
365 holiday_country,
366 reminder_state_file,
367 keybindings,
368 providers,
369 })
370 }
371
372 fn parse_holiday_source(value: &str, path: &Path) -> Result<ConfigHolidaySource, ConfigError> {
373 match value {
374 "off" => Ok(ConfigHolidaySource::Off),
375 "us-federal" => Ok(ConfigHolidaySource::UsFederal),
376 "nager" => Ok(ConfigHolidaySource::Nager),
377 _ => Err(ConfigError::Invalid {
378 path: path.to_path_buf(),
379 reason: format!(
380 "invalid holidays.source '{value}'; expected off, us-federal, or nager"
381 ),
382 }),
383 }
384 }
385
386 fn parse_holiday_country(value: &str, path: &Path) -> Result<String, ConfigError> {
387 if value.len() == 2 && value.bytes().all(|value| value.is_ascii_alphabetic()) {
388 Ok(value.to_ascii_uppercase())
389 } else {
390 Err(ConfigError::Invalid {
391 path: path.to_path_buf(),
392 reason: format!("invalid holidays.country '{value}'; expected two ASCII letters"),
393 })
394 }
395 }
396
397 fn resolve_config_path(value: &str, base_dir: &Path) -> Result<PathBuf, ConfigError> {
398 let expanded = expand_user_path(PathBuf::from(value))?;
399 if expanded.is_absolute() {
400 Ok(expanded)
401 } else {
402 Ok(base_dir.join(expanded))
403 }
404 }
405
406 pub fn expand_user_path(path: PathBuf) -> Result<PathBuf, ConfigError> {
407 let Some(value) = path.to_str() else {
408 return Ok(path);
409 };
410
411 if value == "~" {
412 return Ok(home_dir()?.to_path_buf());
413 }
414
415 if let Some(rest) = value.strip_prefix("~/") {
416 return Ok(home_dir()?.join(rest));
417 }
418
419 Ok(path)
420 }
421
422 fn home_dir() -> Result<PathBuf, ConfigError> {
423 env::var_os("HOME")
424 .map(PathBuf::from)
425 .ok_or(ConfigError::MissingHome)
426 }
427
428 #[derive(Debug, Deserialize)]
429 #[serde(deny_unknown_fields)]
430 struct RawConfig {
431 paths: Option<RawPathsConfig>,
432 holidays: Option<RawHolidaysConfig>,
433 reminders: Option<RawRemindersConfig>,
434 providers: Option<RawProvidersConfig>,
435 keybindings: Option<RawKeyBindingsConfig>,
436 }
437
438 #[derive(Debug, Deserialize)]
439 #[serde(deny_unknown_fields)]
440 struct RawPathsConfig {
441 events_file: Option<String>,
442 }
443
444 #[derive(Debug, Deserialize)]
445 #[serde(deny_unknown_fields)]
446 struct RawHolidaysConfig {
447 source: Option<String>,
448 country: Option<String>,
449 }
450
451 #[derive(Debug, Deserialize)]
452 #[serde(deny_unknown_fields)]
453 struct RawRemindersConfig {
454 state_file: Option<String>,
455 }
456
457 #[derive(Debug, Default, Deserialize)]
458 #[serde(deny_unknown_fields)]
459 struct RawProvidersConfig {
460 create_target: Option<String>,
461 microsoft: Option<RawMicrosoftProviderConfig>,
462 }
463
464 #[derive(Debug, Default, Deserialize)]
465 #[serde(deny_unknown_fields)]
466 struct RawMicrosoftProviderConfig {
467 enabled: Option<bool>,
468 default_account: Option<String>,
469 default_calendar: Option<String>,
470 sync_past_days: Option<i32>,
471 sync_future_days: Option<i32>,
472 cache_file: Option<String>,
473 accounts: Option<Vec<RawMicrosoftAccountConfig>>,
474 }
475
476 #[derive(Debug, Deserialize)]
477 #[serde(deny_unknown_fields)]
478 struct RawMicrosoftAccountConfig {
479 id: String,
480 client_id: Option<String>,
481 tenant: Option<String>,
482 redirect_port: Option<u16>,
483 calendars: Option<Vec<String>>,
484 }
485
486 #[derive(Debug, Default, Deserialize)]
487 #[serde(deny_unknown_fields)]
488 struct RawKeyBindingsConfig {
489 move_left: Option<Vec<String>>,
490 move_right: Option<Vec<String>>,
491 move_up: Option<Vec<String>>,
492 move_down: Option<Vec<String>>,
493 open_day_or_edit: Option<Vec<String>>,
494 close_day: Option<Vec<String>>,
495 create_event: Option<Vec<String>>,
496 delete_event: Option<Vec<String>>,
497 copy_event: Option<Vec<String>>,
498 help: Option<Vec<String>>,
499 quit: Option<Vec<String>>,
500 jump_monday: Option<Vec<String>>,
501 jump_tuesday: Option<Vec<String>>,
502 jump_wednesday: Option<Vec<String>>,
503 jump_thursday: Option<Vec<String>>,
504 jump_friday: Option<Vec<String>>,
505 jump_saturday: Option<Vec<String>>,
506 jump_sunday: Option<Vec<String>>,
507 }
508
509 impl RawKeyBindingsConfig {
510 fn into_overrides(self) -> KeyBindingOverrides {
511 KeyBindingOverrides {
512 move_left: self.move_left,
513 move_right: self.move_right,
514 move_up: self.move_up,
515 move_down: self.move_down,
516 open_day_or_edit: self.open_day_or_edit,
517 close_day: self.close_day,
518 create_event: self.create_event,
519 delete_event: self.delete_event,
520 copy_event: self.copy_event,
521 help: self.help,
522 quit: self.quit,
523 jump_monday: self.jump_monday,
524 jump_tuesday: self.jump_tuesday,
525 jump_wednesday: self.jump_wednesday,
526 jump_thursday: self.jump_thursday,
527 jump_friday: self.jump_friday,
528 jump_saturday: self.jump_saturday,
529 jump_sunday: self.jump_sunday,
530 }
531 }
532 }
533
534 impl RawProvidersConfig {
535 fn into_config(self, path: &Path, base_dir: &Path) -> Result<ProviderConfig, ConfigError> {
536 let mut config = ProviderConfig::default();
537 let create_target_was_set = self.create_target.is_some();
538 if let Some(create_target) = self.create_target {
539 config.create_target = parse_create_target(&create_target, path)?;
540 }
541 if let Some(microsoft) = self.microsoft {
542 config.microsoft = microsoft.into_config(path, base_dir)?;
543 if config.microsoft.enabled && !create_target_was_set {
544 config.create_target = ProviderCreateTarget::Microsoft;
545 }
546 }
547 config
548 .microsoft
549 .validate()
550 .map_err(|err| ConfigError::Invalid {
551 path: path.to_path_buf(),
552 reason: err.to_string(),
553 })?;
554 Ok(config)
555 }
556 }
557
558 impl RawMicrosoftProviderConfig {
559 fn into_config(
560 self,
561 path: &Path,
562 base_dir: &Path,
563 ) -> Result<MicrosoftProviderConfig, ConfigError> {
564 let mut config = MicrosoftProviderConfig::default();
565 if let Some(enabled) = self.enabled {
566 config.enabled = enabled;
567 }
568 config.default_account = self.default_account;
569 config.default_calendar = self.default_calendar;
570 if let Some(sync_past_days) = self.sync_past_days {
571 config.sync_past_days = sync_past_days.max(0);
572 }
573 if let Some(sync_future_days) = self.sync_future_days {
574 config.sync_future_days = sync_future_days.max(1);
575 }
576 if let Some(cache_file) = self.cache_file {
577 config.cache_file = resolve_config_path(&cache_file, base_dir)?;
578 }
579 config.accounts = self
580 .accounts
581 .unwrap_or_default()
582 .into_iter()
583 .map(RawMicrosoftAccountConfig::into_config)
584 .collect();
585 config.validate().map_err(|err| ConfigError::Invalid {
586 path: path.to_path_buf(),
587 reason: err.to_string(),
588 })?;
589 Ok(config)
590 }
591 }
592
593 impl RawMicrosoftAccountConfig {
594 fn into_config(self) -> MicrosoftAccountConfig {
595 MicrosoftAccountConfig {
596 id: self.id,
597 client_id: self
598 .client_id
599 .unwrap_or_else(|| MICROSOFT_OFFICIAL_CLIENT_ID.to_string()),
600 tenant: self
601 .tenant
602 .unwrap_or_else(|| MICROSOFT_DEFAULT_TENANT.to_string()),
603 redirect_port: self.redirect_port.unwrap_or(8765),
604 calendars: self.calendars.unwrap_or_default(),
605 }
606 }
607 }
608
609 fn parse_create_target(value: &str, path: &Path) -> Result<ProviderCreateTarget, ConfigError> {
610 match value {
611 "local" => Ok(ProviderCreateTarget::Local),
612 "microsoft" => Ok(ProviderCreateTarget::Microsoft),
613 _ => Err(ConfigError::Invalid {
614 path: path.to_path_buf(),
615 reason: format!(
616 "invalid providers.create_target '{value}'; expected local or microsoft"
617 ),
618 }),
619 }
620 }
621
622 #[derive(Debug, Clone, PartialEq, Eq)]
623 pub enum ConfigError {
624 Missing { path: PathBuf },
625 MissingHome,
626 AlreadyExists { path: PathBuf },
627 Read { path: PathBuf, reason: String },
628 Write { path: PathBuf, reason: String },
629 Parse { path: PathBuf, reason: String },
630 Invalid { path: PathBuf, reason: String },
631 }
632
633 impl fmt::Display for ConfigError {
634 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635 match self {
636 Self::Missing { path } => write!(f, "config file does not exist: {}", path.display()),
637 Self::MissingHome => write!(f, "failed to locate a user home directory"),
638 Self::AlreadyExists { path } => {
639 write!(
640 f,
641 "config file already exists at {}; pass --force to overwrite",
642 path.display()
643 )
644 }
645 Self::Read { path, reason } => {
646 write!(f, "failed to read config {}: {reason}", path.display())
647 }
648 Self::Write { path, reason } => {
649 write!(f, "failed to write config {}: {reason}", path.display())
650 }
651 Self::Parse { path, reason } => {
652 write!(f, "failed to parse config {}: {reason}", path.display())
653 }
654 Self::Invalid { path, reason } => {
655 write!(f, "invalid config {}: {reason}", path.display())
656 }
657 }
658 }
659 }
660
661 impl Error for ConfigError {}
662
663 impl From<KeyBindingError> for ConfigError {
664 fn from(err: KeyBindingError) -> Self {
665 Self::Invalid {
666 path: PathBuf::from("<config>"),
667 reason: err.to_string(),
668 }
669 }
670 }
671
672 #[cfg(test)]
673 mod tests {
674 use super::*;
675 use std::sync::atomic::{AtomicUsize, Ordering};
676
677 static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
678
679 fn temp_config_path(name: &str) -> PathBuf {
680 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
681 env::temp_dir()
682 .join(format!("rcal-config-test-{}-{counter}", std::process::id()))
683 .join(name)
684 }
685
686 #[test]
687 fn default_config_path_prefers_xdg_then_home() {
688 assert_eq!(
689 default_config_file_for(Some("/tmp/xdg"), Some("/tmp/home")),
690 PathBuf::from("/tmp/xdg/rcal/config.toml")
691 );
692 assert_eq!(
693 default_config_file_for(None::<&str>, Some("/tmp/home")),
694 PathBuf::from("/tmp/home/.config/rcal/config.toml")
695 );
696 }
697
698 #[test]
699 fn missing_discovered_config_is_empty() {
700 let config = UserConfig::empty();
701 assert!(config.events_file.is_none());
702 assert!(config.reminder_state_file.is_none());
703 }
704
705 #[test]
706 fn config_loads_paths_relative_to_file_and_expands_home() {
707 let path = temp_config_path("relative/config.toml");
708 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
709 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
710 fs::write(
711 &path,
712 r#"
713 [paths]
714 events_file = "events.json"
715
716 [reminders]
717 state_file = "~/state.json"
718 "#,
719 )
720 .expect("config writes");
721
722 let config = load_config_file(&path).expect("config loads");
723 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
724
725 let expected_events_file = path.parent().expect("config dir").join("events.json");
726 assert_eq!(
727 config.events_file.as_deref(),
728 Some(expected_events_file.as_path())
729 );
730 assert!(
731 config
732 .reminder_state_file
733 .expect("state path")
734 .ends_with("state.json")
735 );
736 }
737
738 #[test]
739 fn generated_config_parses_cleanly() {
740 let parsed = toml::from_str::<RawConfig>(DEFAULT_CONFIG_TOML).expect("template parses");
741 assert!(parsed.paths.expect("paths").events_file.is_some());
742 assert!(parsed.keybindings.expect("keybindings").quit.is_some());
743 }
744
745 #[test]
746 fn microsoft_provider_config_parses_and_resolves_paths() {
747 let path = temp_config_path("providers/config.toml");
748 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
749 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
750 fs::write(
751 &path,
752 r#"
753 [providers]
754 create_target = "microsoft"
755
756 [providers.microsoft]
757 enabled = true
758 default_account = "work"
759 default_calendar = "cal-1"
760 sync_past_days = 7
761 sync_future_days = 90
762 cache_file = "microsoft-cache.json"
763
764 [[providers.microsoft.accounts]]
765 id = "work"
766 client_id = "client-id"
767 tenant = "organizations"
768 redirect_port = 9001
769 calendars = ["cal-1"]
770 "#,
771 )
772 .expect("config writes");
773
774 let config = load_config_file(&path).expect("config loads");
775 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
776
777 assert_eq!(
778 config.providers.create_target,
779 ProviderCreateTarget::Microsoft
780 );
781 assert!(config.providers.microsoft.enabled);
782 assert_eq!(
783 config.providers.microsoft.cache_file,
784 path.parent()
785 .expect("config dir")
786 .join("microsoft-cache.json")
787 );
788 assert_eq!(config.providers.microsoft.sync_past_days, 7);
789 assert_eq!(config.providers.microsoft.sync_future_days, 90);
790 assert_eq!(config.providers.microsoft.accounts[0].redirect_port, 9001);
791 }
792
793 #[test]
794 fn microsoft_provider_defaults_to_official_public_client() {
795 let path = temp_config_path("providers-official/config.toml");
796 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
797 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
798 fs::write(
799 &path,
800 r#"
801 [providers.microsoft]
802 enabled = true
803 default_account = "work"
804 default_calendar = "cal-1"
805
806 [[providers.microsoft.accounts]]
807 id = "work"
808 calendars = ["cal-1"]
809 "#,
810 )
811 .expect("config writes");
812
813 let config = load_config_file(&path).expect("config loads");
814 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
815
816 let account = &config.providers.microsoft.accounts[0];
817 assert_eq!(account.client_id, MICROSOFT_OFFICIAL_CLIENT_ID);
818 assert_eq!(account.tenant, MICROSOFT_DEFAULT_TENANT);
819 }
820
821 #[test]
822 fn microsoft_setup_config_preserves_unrelated_sections() {
823 let path = temp_config_path("providers-setup/config.toml");
824 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
825 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
826 fs::write(
827 &path,
828 r#"
829 [paths]
830 events_file = "./events.json"
831
832 [providers]
833 create_target = "local"
834
835 [providers.microsoft]
836 enabled = false
837
838 [[providers.microsoft.accounts]]
839 id = "old"
840 calendars = []
841
842 [keybindings]
843 quit = ["q"]
844 "#,
845 )
846 .expect("config writes");
847
848 write_microsoft_setup_config(
849 Some(path.clone()),
850 &MicrosoftSetupConfig {
851 account_id: "work".to_string(),
852 calendar_id: "cal-1".to_string(),
853 calendar_name: "Calendar\nInjected".to_string(),
854 sync_past_days: 7,
855 sync_future_days: 90,
856 redirect_port: 9001,
857 },
858 )
859 .expect("setup config writes");
860 let body = fs::read_to_string(&path).expect("config reads");
861 let config = load_config_file(&path).expect("config loads");
862 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
863
864 assert!(body.contains("[paths]"));
865 assert!(body.contains("[keybindings]"));
866 assert!(body.contains("# Selected calendar: Calendar Injected"));
867 assert!(!body.contains("id = \"old\""));
868 assert_eq!(
869 config.providers.create_target,
870 ProviderCreateTarget::Microsoft
871 );
872 assert_eq!(
873 config.providers.microsoft.default_calendar.as_deref(),
874 Some("cal-1")
875 );
876 assert_eq!(config.providers.microsoft.accounts[0].redirect_port, 9001);
877 }
878
879 #[test]
880 fn invalid_microsoft_provider_config_fails_clearly() {
881 let path = temp_config_path("providers-invalid/config.toml");
882 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
883 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
884 fs::write(
885 &path,
886 r#"
887 [providers.microsoft]
888 enabled = true
889 default_account = "work"
890 default_calendar = "missing"
891
892 [[providers.microsoft.accounts]]
893 id = "work"
894 client_id = "client-id"
895 tenant = "organizations"
896 calendars = ["cal-1"]
897 "#,
898 )
899 .expect("config writes");
900
901 let err = load_config_file(&path).expect_err("invalid provider config fails");
902 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
903
904 assert!(err.to_string().contains("default calendar"));
905 }
906
907 #[test]
908 fn malformed_and_unknown_config_fail_clearly() {
909 assert!(toml::from_str::<RawConfig>("not = [").is_err());
910 assert!(toml::from_str::<RawConfig>("[unknown]\nvalue = true\n").is_err());
911 }
912
913 #[test]
914 fn config_init_refuses_to_overwrite_without_force() {
915 let path = temp_config_path("init/config.toml");
916 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
917 let created = init_config_file(Some(path.clone()), false).expect("config initializes");
918 assert_eq!(created, path);
919 let err = init_config_file(Some(path.clone()), false).expect_err("overwrite fails");
920 init_config_file(Some(path.clone()), true).expect("force overwrites");
921 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
922 assert!(matches!(err, ConfigError::AlreadyExists { .. }));
923 }
924
925 #[test]
926 fn invalid_keybindings_fail() {
927 let path = temp_config_path("keys/config.toml");
928 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
929 fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
930 fs::write(
931 &path,
932 r#"
933 [keybindings]
934 create_event = ["1"]
935 "#,
936 )
937 .expect("config writes");
938
939 let err = load_config_file(&path).expect_err("digit key fails");
940 let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
941 assert!(err.to_string().contains("reserved for day jumps"));
942 }
943 }
944