| 1 | //! Configuration: loading, defaults, validation and the MPD address. |
| 2 | //! |
| 3 | //! `default.toml` is the single source of truth for defaults. A user file is |
| 4 | //! merged over it table by table, so partial files keep every default they |
| 5 | //! leave out. |
| 6 | |
| 7 | mod keys; |
| 8 | mod theme; |
| 9 | |
| 10 | use std::{ |
| 11 | env, |
| 12 | fs::{self, OpenOptions}, |
| 13 | io::{self, Write}, |
| 14 | net::Ipv6Addr, |
| 15 | path::{Path, PathBuf}, |
| 16 | time::Duration, |
| 17 | }; |
| 18 | |
| 19 | use serde::Deserialize; |
| 20 | |
| 21 | pub use keys::{Bindings, KeyCombo, Keymap}; |
| 22 | pub use theme::Theme; |
| 23 | |
| 24 | use crate::{action::ScreenId, mpd::Address, util::expand_tilde}; |
| 25 | |
| 26 | pub const DEFAULT_CONFIG: &str = include_str!("default.toml"); |
| 27 | |
| 28 | #[derive(Debug, thiserror::Error)] |
| 29 | pub enum ConfigError { |
| 30 | #[error("cannot access {}: {source}", path.display())] |
| 31 | Io { path: PathBuf, source: io::Error }, |
| 32 | #[error("invalid configuration in {}: {message}", path.display())] |
| 33 | Parse { path: PathBuf, message: String }, |
| 34 | #[error("invalid configuration: {0}")] |
| 35 | Invalid(String), |
| 36 | #[error( |
| 37 | "{} uses the rmptui 0.1 format, which 0.2 no longer reads.\n\ |
| 38 | Move it aside (e.g. to config.toml.old) and start rmptui again to \ |
| 39 | generate a documented 0.2 configuration, then copy your settings over.", |
| 40 | .0.display() |
| 41 | )] |
| 42 | Legacy(PathBuf), |
| 43 | } |
| 44 | |
| 45 | #[derive(Clone, Debug, Deserialize)] |
| 46 | #[serde(deny_unknown_fields)] |
| 47 | pub struct MpdConfig { |
| 48 | pub host: String, |
| 49 | pub port: u16, |
| 50 | pub password: String, |
| 51 | pub music_directory: String, |
| 52 | } |
| 53 | |
| 54 | #[derive(Clone, Debug, Deserialize)] |
| 55 | #[serde(deny_unknown_fields)] |
| 56 | pub struct PlaybackConfig { |
| 57 | pub seek_seconds: u32, |
| 58 | pub volume_step: u8, |
| 59 | } |
| 60 | |
| 61 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] |
| 62 | #[serde(rename_all = "snake_case")] |
| 63 | pub enum ColumnField { |
| 64 | Artist, |
| 65 | AlbumArtist, |
| 66 | Title, |
| 67 | Album, |
| 68 | Track, |
| 69 | Disc, |
| 70 | Date, |
| 71 | Genre, |
| 72 | Duration, |
| 73 | File, |
| 74 | } |
| 75 | |
| 76 | impl ColumnField { |
| 77 | pub fn title(self) -> &'static str { |
| 78 | match self { |
| 79 | Self::Artist => "Artist", |
| 80 | Self::AlbumArtist => "Album Artist", |
| 81 | Self::Title => "Title", |
| 82 | Self::Album => "Album", |
| 83 | Self::Track => "#", |
| 84 | Self::Disc => "Disc", |
| 85 | Self::Date => "Date", |
| 86 | Self::Genre => "Genre", |
| 87 | Self::Duration => "Time", |
| 88 | Self::File => "File", |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | #[derive(Clone, Copy, Debug, Deserialize)] |
| 94 | #[serde(deny_unknown_fields)] |
| 95 | pub struct Column { |
| 96 | pub field: ColumnField, |
| 97 | pub width: u16, |
| 98 | } |
| 99 | |
| 100 | #[derive(Clone, Debug, Deserialize)] |
| 101 | #[serde(deny_unknown_fields)] |
| 102 | pub struct UiConfig { |
| 103 | pub startup_screen: ScreenId, |
| 104 | pub mouse: bool, |
| 105 | pub lyrics_pane: bool, |
| 106 | pub lyrics_pane_width: u16, |
| 107 | pub message_timeout: u64, |
| 108 | pub queue_columns: Vec<Column>, |
| 109 | } |
| 110 | |
| 111 | #[derive(Clone, Debug, Deserialize)] |
| 112 | #[serde(deny_unknown_fields)] |
| 113 | pub struct LyricsConfig { |
| 114 | pub online: bool, |
| 115 | pub cache: bool, |
| 116 | pub lrclib_url: String, |
| 117 | } |
| 118 | |
| 119 | #[derive(Clone, Debug, Deserialize)] |
| 120 | #[serde(deny_unknown_fields)] |
| 121 | pub struct VisualizerConfig { |
| 122 | pub fifo: PathBuf, |
| 123 | pub sample_rate: u32, |
| 124 | pub channels: u16, |
| 125 | pub framerate: u32, |
| 126 | pub bar_width: u16, |
| 127 | pub bar_gap: u16, |
| 128 | pub min_frequency: f32, |
| 129 | pub max_frequency: f32, |
| 130 | pub falloff: f32, |
| 131 | } |
| 132 | |
| 133 | #[derive(Debug, Deserialize)] |
| 134 | #[serde(deny_unknown_fields)] |
| 135 | struct RawConfig { |
| 136 | mpd: MpdConfig, |
| 137 | playback: PlaybackConfig, |
| 138 | ui: UiConfig, |
| 139 | theme: Theme, |
| 140 | lyrics: LyricsConfig, |
| 141 | visualizer: VisualizerConfig, |
| 142 | keys: Bindings, |
| 143 | } |
| 144 | |
| 145 | #[derive(Clone, Debug)] |
| 146 | pub struct Config { |
| 147 | pub mpd: MpdConfig, |
| 148 | pub playback: PlaybackConfig, |
| 149 | pub ui: UiConfig, |
| 150 | pub theme: Theme, |
| 151 | pub lyrics: LyricsConfig, |
| 152 | pub visualizer: VisualizerConfig, |
| 153 | pub keymap: Keymap, |
| 154 | } |
| 155 | |
| 156 | impl Default for Config { |
| 157 | fn default() -> Self { |
| 158 | Self::from_toml(DEFAULT_CONFIG).expect("the built-in default configuration is valid") |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | impl Config { |
| 163 | /// Loads the config at `path`, or at the default location. A missing |
| 164 | /// file at the default location is created from the documented defaults. |
| 165 | pub fn load(path: Option<&Path>) -> Result<(Self, PathBuf), ConfigError> { |
| 166 | let (path, create) = match path { |
| 167 | Some(path) => (path.to_path_buf(), false), |
| 168 | None => (default_path(), true), |
| 169 | }; |
| 170 | let io_error = |source| ConfigError::Io { |
| 171 | path: path.clone(), |
| 172 | source, |
| 173 | }; |
| 174 | let contents = match fs::read_to_string(&path) { |
| 175 | Ok(contents) => contents, |
| 176 | Err(error) if error.kind() == io::ErrorKind::NotFound && create => { |
| 177 | write_default(&path).map_err(io_error)?; |
| 178 | DEFAULT_CONFIG.to_owned() |
| 179 | } |
| 180 | Err(error) => return Err(io_error(error)), |
| 181 | }; |
| 182 | let user: toml::Table = toml::from_str(&contents).map_err(|error| ConfigError::Parse { |
| 183 | path: path.clone(), |
| 184 | message: error.to_string(), |
| 185 | })?; |
| 186 | if user.contains_key("keybindings") { |
| 187 | return Err(ConfigError::Legacy(path)); |
| 188 | } |
| 189 | let config = Self::from_table(user).map_err(|error| match error { |
| 190 | ConfigError::Invalid(message) => ConfigError::Parse { |
| 191 | path: path.clone(), |
| 192 | message, |
| 193 | }, |
| 194 | other => other, |
| 195 | })?; |
| 196 | Ok((config, path)) |
| 197 | } |
| 198 | |
| 199 | /// Parses a (partial) config, filling in defaults. |
| 200 | pub fn from_toml(contents: &str) -> Result<Self, ConfigError> { |
| 201 | let user: toml::Table = |
| 202 | toml::from_str(contents).map_err(|error| ConfigError::Invalid(error.to_string()))?; |
| 203 | Self::from_table(user) |
| 204 | } |
| 205 | |
| 206 | fn from_table(user: toml::Table) -> Result<Self, ConfigError> { |
| 207 | let mut merged: toml::Table = |
| 208 | toml::from_str(DEFAULT_CONFIG).expect("the built-in default configuration is TOML"); |
| 209 | merge(&mut merged, user); |
| 210 | let raw: RawConfig = toml::Value::Table(merged) |
| 211 | .try_into() |
| 212 | .map_err(|error: toml::de::Error| ConfigError::Invalid(error.to_string()))?; |
| 213 | let config = Self { |
| 214 | keymap: Keymap::new(raw.keys).map_err(ConfigError::Invalid)?, |
| 215 | mpd: raw.mpd, |
| 216 | playback: raw.playback, |
| 217 | ui: raw.ui, |
| 218 | theme: raw.theme, |
| 219 | lyrics: raw.lyrics, |
| 220 | visualizer: raw.visualizer, |
| 221 | }; |
| 222 | config.validate()?; |
| 223 | Ok(config) |
| 224 | } |
| 225 | |
| 226 | fn validate(&self) -> Result<(), ConfigError> { |
| 227 | let check = |ok: bool, message: &str| { |
| 228 | if ok { |
| 229 | Ok(()) |
| 230 | } else { |
| 231 | Err(ConfigError::Invalid(message.to_owned())) |
| 232 | } |
| 233 | }; |
| 234 | check(!self.mpd.host.trim().is_empty(), "mpd.host cannot be empty")?; |
| 235 | check(self.mpd.port != 0, "mpd.port must be between 1 and 65535")?; |
| 236 | check( |
| 237 | self.playback.seek_seconds > 0, |
| 238 | "playback.seek_seconds must be greater than zero", |
| 239 | )?; |
| 240 | check( |
| 241 | (1..=100).contains(&self.playback.volume_step), |
| 242 | "playback.volume_step must be between 1 and 100", |
| 243 | )?; |
| 244 | check( |
| 245 | (10..=90).contains(&self.ui.lyrics_pane_width), |
| 246 | "ui.lyrics_pane_width must be between 10 and 90", |
| 247 | )?; |
| 248 | check( |
| 249 | !self.ui.queue_columns.is_empty(), |
| 250 | "ui.queue_columns cannot be empty", |
| 251 | )?; |
| 252 | let vis = &self.visualizer; |
| 253 | check( |
| 254 | vis.sample_rate > 0, |
| 255 | "visualizer.sample_rate must be greater than zero", |
| 256 | )?; |
| 257 | check( |
| 258 | matches!(vis.channels, 1 | 2), |
| 259 | "visualizer.channels must be 1 or 2", |
| 260 | )?; |
| 261 | check( |
| 262 | (1..=240).contains(&vis.framerate), |
| 263 | "visualizer.framerate must be between 1 and 240", |
| 264 | )?; |
| 265 | check( |
| 266 | vis.bar_width > 0, |
| 267 | "visualizer.bar_width must be greater than zero", |
| 268 | )?; |
| 269 | check( |
| 270 | vis.min_frequency > 0.0 && vis.min_frequency < vis.max_frequency, |
| 271 | "visualizer.min_frequency must be positive and below max_frequency", |
| 272 | )?; |
| 273 | check( |
| 274 | vis.falloff > 0.0, |
| 275 | "visualizer.falloff must be greater than zero", |
| 276 | )?; |
| 277 | check( |
| 278 | !self.theme.visualizer.is_empty(), |
| 279 | "theme.visualizer needs at least one color", |
| 280 | )?; |
| 281 | Ok(()) |
| 282 | } |
| 283 | |
| 284 | /// The music directory from the config, if set. |
| 285 | pub fn music_directory(&self) -> Option<PathBuf> { |
| 286 | let dir = self.mpd.music_directory.trim(); |
| 287 | (!dir.is_empty()).then(|| expand_tilde(dir)) |
| 288 | } |
| 289 | |
| 290 | pub fn message_timeout(&self) -> Duration { |
| 291 | Duration::from_secs(self.ui.message_timeout) |
| 292 | } |
| 293 | |
| 294 | /// The MPD address and password, honoring `MPD_HOST` and `MPD_PORT`. |
| 295 | pub fn mpd_connection(&self) -> Result<(Address, Option<String>), ConfigError> { |
| 296 | resolve_connection( |
| 297 | &self.mpd, |
| 298 | env::var("MPD_HOST").ok().filter(|h| !h.is_empty()), |
| 299 | env::var("MPD_PORT").ok().filter(|p| !p.is_empty()), |
| 300 | ) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | /// Recursively merges `user` into `base`. Tables merge; other values replace. |
| 305 | fn merge(base: &mut toml::Table, user: toml::Table) { |
| 306 | for (key, value) in user { |
| 307 | match (base.get_mut(&key), value) { |
| 308 | (Some(toml::Value::Table(base)), toml::Value::Table(user)) => merge(base, user), |
| 309 | (_, value) => { |
| 310 | base.insert(key, value); |
| 311 | } |
| 312 | } |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | /// `$CONFIG_HOME/rmptui/config.toml`, falling back to `$XDG_CONFIG_HOME` |
| 317 | /// and then `~/.config`. |
| 318 | pub fn default_path() -> PathBuf { |
| 319 | env::var_os("CONFIG_HOME") |
| 320 | .or_else(|| env::var_os("XDG_CONFIG_HOME")) |
| 321 | .filter(|dir| !dir.is_empty()) |
| 322 | .map(PathBuf::from) |
| 323 | .unwrap_or_else(|| expand_tilde("~/.config")) |
| 324 | .join("rmptui") |
| 325 | .join("config.toml") |
| 326 | } |
| 327 | |
| 328 | fn write_default(path: &Path) -> io::Result<()> { |
| 329 | if let Some(parent) = path.parent() { |
| 330 | fs::create_dir_all(parent)?; |
| 331 | } |
| 332 | let mut options = OpenOptions::new(); |
| 333 | options.write(true).create_new(true); |
| 334 | #[cfg(unix)] |
| 335 | { |
| 336 | use std::os::unix::fs::OpenOptionsExt; |
| 337 | // The file may hold the MPD password. |
| 338 | options.mode(0o600); |
| 339 | } |
| 340 | match options.open(path) { |
| 341 | Ok(mut file) => file.write_all(DEFAULT_CONFIG.as_bytes()), |
| 342 | // Another instance created it in the meantime. |
| 343 | Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()), |
| 344 | Err(error) => Err(error), |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | fn resolve_connection( |
| 349 | mpd: &MpdConfig, |
| 350 | env_host: Option<String>, |
| 351 | env_port: Option<String>, |
| 352 | ) -> Result<(Address, Option<String>), ConfigError> { |
| 353 | let configured_password = (!mpd.password.is_empty()).then(|| mpd.password.clone()); |
| 354 | let (host, password) = match env_host { |
| 355 | Some(host) => match host.split_once('@') { |
| 356 | Some((password, host)) => (host.to_owned(), Some(password.to_owned())), |
| 357 | None => (host, configured_password), |
| 358 | }, |
| 359 | None => (mpd.host.clone(), configured_password), |
| 360 | }; |
| 361 | if host.is_empty() { |
| 362 | return Err(ConfigError::Invalid("MPD host cannot be empty".into())); |
| 363 | } |
| 364 | if host.starts_with('/') || host.starts_with('~') { |
| 365 | return Ok((Address::Unix(expand_tilde(&host)), password)); |
| 366 | } |
| 367 | let port = match env_port { |
| 368 | Some(port) => port |
| 369 | .parse::<u16>() |
| 370 | .ok() |
| 371 | .filter(|&p| p != 0) |
| 372 | .ok_or_else(|| ConfigError::Invalid(format!("invalid MPD_PORT value `{port}`")))?, |
| 373 | None => mpd.port, |
| 374 | }; |
| 375 | Ok((Address::Tcp(tcp_address(&host, port)), password)) |
| 376 | } |
| 377 | |
| 378 | /// Formats `host` and `port` for connecting, keeping an explicit port in |
| 379 | /// `host` and bracketing IPv6 addresses. |
| 380 | fn tcp_address(host: &str, port: u16) -> String { |
| 381 | if host.starts_with('[') && host.contains("]:") { |
| 382 | host.to_owned() |
| 383 | } else if let Some(ipv6) = host |
| 384 | .strip_prefix('[') |
| 385 | .and_then(|h| h.strip_suffix(']')) |
| 386 | .filter(|h| h.parse::<Ipv6Addr>().is_ok()) |
| 387 | { |
| 388 | format!("[{ipv6}]:{port}") |
| 389 | } else if host.parse::<Ipv6Addr>().is_ok() { |
| 390 | format!("[{host}]:{port}") |
| 391 | } else if host |
| 392 | .rsplit_once(':') |
| 393 | .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok()) |
| 394 | { |
| 395 | host.to_owned() |
| 396 | } else { |
| 397 | format!("{host}:{port}") |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | #[cfg(test)] |
| 402 | mod tests { |
| 403 | use super::*; |
| 404 | use crate::action::{Action, Context}; |
| 405 | |
| 406 | #[test] |
| 407 | fn default_config_is_valid_and_binds_every_action() { |
| 408 | let config = Config::default(); |
| 409 | for &action in Action::ALL { |
| 410 | let bound = action |
| 411 | .contexts() |
| 412 | .iter() |
| 413 | .any(|&context| !config.keymap.keys_for(context, action).is_empty()); |
| 414 | assert!(bound, "action `{action}` has no default binding"); |
| 415 | } |
| 416 | assert_eq!(config.mpd.port, 6600); |
| 417 | assert_eq!(config.ui.startup_screen, ScreenId::Queue); |
| 418 | } |
| 419 | |
| 420 | #[test] |
| 421 | fn partial_config_keeps_defaults() { |
| 422 | let config = Config::from_toml( |
| 423 | r#" |
| 424 | [playback] |
| 425 | volume_step = 10 |
| 426 | [keys.global] |
| 427 | quit = ["Q"] |
| 428 | "#, |
| 429 | ) |
| 430 | .unwrap(); |
| 431 | assert_eq!(config.playback.volume_step, 10); |
| 432 | assert_eq!(config.playback.seek_seconds, 5); |
| 433 | let quit = config.keymap.keys_for(Context::Global, Action::Quit); |
| 434 | assert_eq!(quit, ["Q".parse::<KeyCombo>().unwrap()]); |
| 435 | // Other global bindings are untouched. |
| 436 | assert!( |
| 437 | !config |
| 438 | .keymap |
| 439 | .keys_for(Context::Global, Action::TogglePause) |
| 440 | .is_empty() |
| 441 | ); |
| 442 | } |
| 443 | |
| 444 | #[test] |
| 445 | fn rejects_bad_values() { |
| 446 | for bad in [ |
| 447 | "[mpd]\nport = 0", |
| 448 | "[playback]\nvolume_step = 0", |
| 449 | "[mpd]\nhots = \"typo\"", |
| 450 | "[theme]\naccent = \"notacolor\"", |
| 451 | "[keys.global]\nquit = [\"Hyper+q\"]", |
| 452 | "[keys.global]\nnot_an_action = [\"x\"]", |
| 453 | // `p` is already bound to toggle_pause. |
| 454 | "[keys.global]\nquit = [\"p\"]", |
| 455 | ] { |
| 456 | assert!(Config::from_toml(bad).is_err(), "accepted `{bad}`"); |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | #[test] |
| 461 | fn detects_legacy_config() { |
| 462 | let dir = Path::new(env!("CARGO_MANIFEST_DIR")) |
| 463 | .join("target/test-tmp") |
| 464 | .join(format!("config-{}", std::process::id())); |
| 465 | fs::create_dir_all(&dir).unwrap(); |
| 466 | let path = dir.join("legacy.toml"); |
| 467 | fs::write(&path, "[keybindings]\nquit = [\"q\"]\n").unwrap(); |
| 468 | let result = Config::load(Some(&path)); |
| 469 | fs::remove_dir_all(&dir).unwrap(); |
| 470 | assert!(matches!(result, Err(ConfigError::Legacy(_)))); |
| 471 | } |
| 472 | |
| 473 | fn mpd_config(host: &str, password: &str) -> MpdConfig { |
| 474 | MpdConfig { |
| 475 | host: host.into(), |
| 476 | port: 6600, |
| 477 | password: password.into(), |
| 478 | music_directory: String::new(), |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn resolves_connection() { |
| 484 | let resolve = |host: &str, env_host: Option<&str>, env_port: Option<&str>| { |
| 485 | resolve_connection( |
| 486 | &mpd_config(host, "cfg"), |
| 487 | env_host.map(Into::into), |
| 488 | env_port.map(Into::into), |
| 489 | ) |
| 490 | .unwrap() |
| 491 | }; |
| 492 | assert_eq!( |
| 493 | resolve("localhost", None, None), |
| 494 | (Address::Tcp("localhost:6600".into()), Some("cfg".into())) |
| 495 | ); |
| 496 | assert_eq!( |
| 497 | resolve("x", Some("secret@host"), Some("6601")), |
| 498 | (Address::Tcp("host:6601".into()), Some("secret".into())) |
| 499 | ); |
| 500 | assert_eq!( |
| 501 | resolve("/run/mpd/socket", None, None).0, |
| 502 | Address::Unix("/run/mpd/socket".into()) |
| 503 | ); |
| 504 | assert!(resolve_connection(&mpd_config("x", ""), None, Some("nope".into())).is_err()); |
| 505 | } |
| 506 | |
| 507 | #[test] |
| 508 | fn formats_tcp_addresses() { |
| 509 | assert_eq!(tcp_address("localhost", 6600), "localhost:6600"); |
| 510 | assert_eq!(tcp_address("localhost:6601", 6600), "localhost:6601"); |
| 511 | assert_eq!(tcp_address("[::1]", 6600), "[::1]:6600"); |
| 512 | assert_eq!(tcp_address("[::1]:6601", 6600), "[::1]:6601"); |
| 513 | assert_eq!(tcp_address("::1", 6600), "[::1]:6600"); |
| 514 | } |
| 515 | } |