Krolyxon
Commit a3a311f

rmptuisrcconfigmod.rs

554 lines · 16.7 KB · Rust

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