| 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 |