Krolyxon
Commit 222ab2f

rmptuisrcconfigmod.rs

518 lines · 15.9 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 sample_rate: u32,
127 pub channels: u16,
128 pub framerate: u32,
129 pub bar_width: u16,
130 pub bar_gap: u16,
131 pub min_frequency: f32,
132 pub max_frequency: f32,
133 pub falloff: f32,
134}
135
136#[derive(Debug, Deserialize)]
137#[serde(deny_unknown_fields)]
138struct RawConfig {
139 mpd: MpdConfig,
140 playback: PlaybackConfig,
141 ui: UiConfig,
142 theme: Theme,
143 lyrics: LyricsConfig,
144 visualizer: VisualizerConfig,
145 keys: Bindings,
146}
147
148#[derive(Clone, Debug)]
149pub struct Config {
150 pub mpd: MpdConfig,
151 pub playback: PlaybackConfig,
152 pub ui: UiConfig,
153 pub theme: Theme,
154 pub lyrics: LyricsConfig,
155 pub visualizer: VisualizerConfig,
156 pub keymap: Keymap,
157}
158
159impl Default for Config {
160 fn default() -> Self {
161 Self::from_toml(DEFAULT_CONFIG).expect("the built-in default configuration is valid")
162 }
163}
164
165impl Config {
166 /// Loads the config at `path`, or at the default location. A missing
167 /// file at the default location is created from the documented defaults.
168 pub fn load(path: Option<&Path>) -> Result<(Self, PathBuf), ConfigError> {
169 let (path, create) = match path {
170 Some(path) => (path.to_path_buf(), false),
171 None => (default_path(), true),
172 };
173 let io_error = |source| ConfigError::Io {
174 path: path.clone(),
175 source,
176 };
177 let contents = match fs::read_to_string(&path) {
178 Ok(contents) => contents,
179 Err(error) if error.kind() == io::ErrorKind::NotFound && create => {
180 write_default(&path).map_err(io_error)?;
181 DEFAULT_CONFIG.to_owned()
182 }
183 Err(error) => return Err(io_error(error)),
184 };
185 let user: toml::Table = toml::from_str(&contents).map_err(|error| ConfigError::Parse {
186 path: path.clone(),
187 message: error.to_string(),
188 })?;
189 if user.contains_key("keybindings") {
190 return Err(ConfigError::Legacy(path));
191 }
192 let config = Self::from_table(user).map_err(|error| match error {
193 ConfigError::Invalid(message) => ConfigError::Parse {
194 path: path.clone(),
195 message,
196 },
197 other => other,
198 })?;
199 Ok((config, path))
200 }
201
202 /// Parses a (partial) config, filling in defaults.
203 pub fn from_toml(contents: &str) -> Result<Self, ConfigError> {
204 let user: toml::Table =
205 toml::from_str(contents).map_err(|error| ConfigError::Invalid(error.to_string()))?;
206 Self::from_table(user)
207 }
208
209 fn from_table(user: toml::Table) -> Result<Self, ConfigError> {
210 let mut merged: toml::Table =
211 toml::from_str(DEFAULT_CONFIG).expect("the built-in default configuration is TOML");
212 merge(&mut merged, user);
213 let raw: RawConfig = toml::Value::Table(merged)
214 .try_into()
215 .map_err(|error: toml::de::Error| ConfigError::Invalid(error.to_string()))?;
216 let config = Self {
217 keymap: Keymap::new(raw.keys).map_err(ConfigError::Invalid)?,
218 mpd: raw.mpd,
219 playback: raw.playback,
220 ui: raw.ui,
221 theme: raw.theme,
222 lyrics: raw.lyrics,
223 visualizer: raw.visualizer,
224 };
225 config.validate()?;
226 Ok(config)
227 }
228
229 fn validate(&self) -> Result<(), ConfigError> {
230 let check = |ok: bool, message: &str| {
231 if ok {
232 Ok(())
233 } else {
234 Err(ConfigError::Invalid(message.to_owned()))
235 }
236 };
237 check(!self.mpd.host.trim().is_empty(), "mpd.host cannot be empty")?;
238 check(self.mpd.port != 0, "mpd.port must be between 1 and 65535")?;
239 check(
240 self.playback.seek_seconds > 0,
241 "playback.seek_seconds must be greater than zero",
242 )?;
243 check(
244 (1..=100).contains(&self.playback.volume_step),
245 "playback.volume_step must be between 1 and 100",
246 )?;
247 check(
248 (10..=90).contains(&self.ui.lyrics_pane_width),
249 "ui.lyrics_pane_width must be between 10 and 90",
250 )?;
251 check(
252 !self.ui.song_columns.is_empty(),
253 "ui.song_columns cannot be empty",
254 )?;
255 let vis = &self.visualizer;
256 check(
257 vis.sample_rate > 0,
258 "visualizer.sample_rate must be greater than zero",
259 )?;
260 check(
261 matches!(vis.channels, 1 | 2),
262 "visualizer.channels must be 1 or 2",
263 )?;
264 check(
265 (1..=240).contains(&vis.framerate),
266 "visualizer.framerate must be between 1 and 240",
267 )?;
268 check(
269 vis.bar_width > 0,
270 "visualizer.bar_width must be greater than zero",
271 )?;
272 check(
273 vis.min_frequency > 0.0 && vis.min_frequency < vis.max_frequency,
274 "visualizer.min_frequency must be positive and below max_frequency",
275 )?;
276 check(
277 vis.falloff > 0.0,
278 "visualizer.falloff must be greater than zero",
279 )?;
280 check(
281 !self.theme.visualizer.is_empty(),
282 "theme.visualizer needs at least one color",
283 )?;
284 Ok(())
285 }
286
287 /// The music directory from the config, if set.
288 pub fn music_directory(&self) -> Option<PathBuf> {
289 let dir = self.mpd.music_directory.trim();
290 (!dir.is_empty()).then(|| expand_tilde(dir))
291 }
292
293 pub fn message_timeout(&self) -> Duration {
294 Duration::from_secs(self.ui.message_timeout)
295 }
296
297 /// The MPD address and password, honoring `MPD_HOST` and `MPD_PORT`.
298 pub fn mpd_connection(&self) -> Result<(Address, Option<String>), ConfigError> {
299 resolve_connection(
300 &self.mpd,
301 env::var("MPD_HOST").ok().filter(|h| !h.is_empty()),
302 env::var("MPD_PORT").ok().filter(|p| !p.is_empty()),
303 )
304 }
305}
306
307/// Recursively merges `user` into `base`. Tables merge; other values replace.
308fn merge(base: &mut toml::Table, user: toml::Table) {
309 for (key, value) in user {
310 match (base.get_mut(&key), value) {
311 (Some(toml::Value::Table(base)), toml::Value::Table(user)) => merge(base, user),
312 (_, value) => {
313 base.insert(key, value);
314 }
315 }
316 }
317}
318
319/// `$CONFIG_HOME/rmptui/config.toml`, falling back to `$XDG_CONFIG_HOME`
320/// and then `~/.config`.
321pub fn default_path() -> PathBuf {
322 env::var_os("CONFIG_HOME")
323 .or_else(|| env::var_os("XDG_CONFIG_HOME"))
324 .filter(|dir| !dir.is_empty())
325 .map(PathBuf::from)
326 .unwrap_or_else(|| expand_tilde("~/.config"))
327 .join("rmptui")
328 .join("config.toml")
329}
330
331fn write_default(path: &Path) -> io::Result<()> {
332 if let Some(parent) = path.parent() {
333 fs::create_dir_all(parent)?;
334 }
335 let mut options = OpenOptions::new();
336 options.write(true).create_new(true);
337 #[cfg(unix)]
338 {
339 use std::os::unix::fs::OpenOptionsExt;
340 // The file may hold the MPD password.
341 options.mode(0o600);
342 }
343 match options.open(path) {
344 Ok(mut file) => file.write_all(DEFAULT_CONFIG.as_bytes()),
345 // Another instance created it in the meantime.
346 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
347 Err(error) => Err(error),
348 }
349}
350
351fn resolve_connection(
352 mpd: &MpdConfig,
353 env_host: Option<String>,
354 env_port: Option<String>,
355) -> Result<(Address, Option<String>), ConfigError> {
356 let configured_password = (!mpd.password.is_empty()).then(|| mpd.password.clone());
357 let (host, password) = match env_host {
358 Some(host) => match host.split_once('@') {
359 Some((password, host)) => (host.to_owned(), Some(password.to_owned())),
360 None => (host, configured_password),
361 },
362 None => (mpd.host.clone(), configured_password),
363 };
364 if host.is_empty() {
365 return Err(ConfigError::Invalid("MPD host cannot be empty".into()));
366 }
367 if host.starts_with('/') || host.starts_with('~') {
368 return Ok((Address::Unix(expand_tilde(&host)), password));
369 }
370 let port = match env_port {
371 Some(port) => port
372 .parse::<u16>()
373 .ok()
374 .filter(|&p| p != 0)
375 .ok_or_else(|| ConfigError::Invalid(format!("invalid MPD_PORT value `{port}`")))?,
376 None => mpd.port,
377 };
378 Ok((Address::Tcp(tcp_address(&host, port)), password))
379}
380
381/// Formats `host` and `port` for connecting, keeping an explicit port in
382/// `host` and bracketing IPv6 addresses.
383fn tcp_address(host: &str, port: u16) -> String {
384 if host.starts_with('[') && host.contains("]:") {
385 host.to_owned()
386 } else if let Some(ipv6) = host
387 .strip_prefix('[')
388 .and_then(|h| h.strip_suffix(']'))
389 .filter(|h| h.parse::<Ipv6Addr>().is_ok())
390 {
391 format!("[{ipv6}]:{port}")
392 } else if host.parse::<Ipv6Addr>().is_ok() {
393 format!("[{host}]:{port}")
394 } else if host
395 .rsplit_once(':')
396 .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok())
397 {
398 host.to_owned()
399 } else {
400 format!("{host}:{port}")
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::action::{Action, Context};
408
409 #[test]
410 fn default_config_is_valid_and_binds_every_action() {
411 let config = Config::default();
412 for &action in Action::ALL {
413 let bound = action
414 .contexts()
415 .iter()
416 .any(|&context| !config.keymap.keys_for(context, action).is_empty());
417 assert!(bound, "action `{action}` has no default binding");
418 }
419 assert_eq!(config.mpd.port, 6600);
420 assert_eq!(config.ui.startup_screen, ScreenId::Queue);
421 }
422
423 #[test]
424 fn partial_config_keeps_defaults() {
425 let config = Config::from_toml(
426 r#"
427 [playback]
428 volume_step = 10
429 [keys.global]
430 quit = ["Q"]
431 "#,
432 )
433 .unwrap();
434 assert_eq!(config.playback.volume_step, 10);
435 assert_eq!(config.playback.seek_seconds, 5);
436 let quit = config.keymap.keys_for(Context::Global, Action::Quit);
437 assert_eq!(quit, ["Q".parse::<KeyCombo>().unwrap()]);
438 // Other global bindings are untouched.
439 assert!(
440 !config
441 .keymap
442 .keys_for(Context::Global, Action::TogglePause)
443 .is_empty()
444 );
445 }
446
447 #[test]
448 fn rejects_bad_values() {
449 for bad in [
450 "[mpd]\nport = 0",
451 "[playback]\nvolume_step = 0",
452 "[mpd]\nhots = \"typo\"",
453 "[theme]\ntext = \"notacolor\"",
454 "[keys.global]\nquit = [\"Hyper+q\"]",
455 "[keys.global]\nnot_an_action = [\"x\"]",
456 // `p` is already bound to toggle_pause.
457 "[keys.global]\nquit = [\"p\"]",
458 ] {
459 assert!(Config::from_toml(bad).is_err(), "accepted `{bad}`");
460 }
461 }
462
463 #[test]
464 fn detects_legacy_config() {
465 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
466 .join("target/test-tmp")
467 .join(format!("config-{}", std::process::id()));
468 fs::create_dir_all(&dir).unwrap();
469 let path = dir.join("legacy.toml");
470 fs::write(&path, "[keybindings]\nquit = [\"q\"]\n").unwrap();
471 let result = Config::load(Some(&path));
472 fs::remove_dir_all(&dir).unwrap();
473 assert!(matches!(result, Err(ConfigError::Legacy(_))));
474 }
475
476 fn mpd_config(host: &str, password: &str) -> MpdConfig {
477 MpdConfig {
478 host: host.into(),
479 port: 6600,
480 password: password.into(),
481 music_directory: String::new(),
482 }
483 }
484
485 #[test]
486 fn resolves_connection() {
487 let resolve = |host: &str, env_host: Option<&str>, env_port: Option<&str>| {
488 resolve_connection(
489 &mpd_config(host, "cfg"),
490 env_host.map(Into::into),
491 env_port.map(Into::into),
492 )
493 .unwrap()
494 };
495 assert_eq!(
496 resolve("localhost", None, None),
497 (Address::Tcp("localhost:6600".into()), Some("cfg".into()))
498 );
499 assert_eq!(
500 resolve("x", Some("secret@host"), Some("6601")),
501 (Address::Tcp("host:6601".into()), Some("secret".into()))
502 );
503 assert_eq!(
504 resolve("/run/mpd/socket", None, None).0,
505 Address::Unix("/run/mpd/socket".into())
506 );
507 assert!(resolve_connection(&mpd_config("x", ""), None, Some("nope".into())).is_err());
508 }
509
510 #[test]
511 fn formats_tcp_addresses() {
512 assert_eq!(tcp_address("localhost", 6600), "localhost:6600");
513 assert_eq!(tcp_address("localhost:6601", 6600), "localhost:6601");
514 assert_eq!(tcp_address("[::1]", 6600), "[::1]:6600");
515 assert_eq!(tcp_address("[::1]:6601", 6600), "[::1]:6601");
516 assert_eq!(tcp_address("::1", 6600), "[::1]:6600");
517 }
518}