Krolyxon
Commit 96dd350

rmptuisrcconfigmod.rs

515 lines · 15.8 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 theme;
9
10use std::{
11 env,
12 fs::{self, OpenOptions},
13 io::{self, Write},
14 net::Ipv6Addr,
15 path::{Path, PathBuf},
16 time::Duration,
17};
18
19use serde::Deserialize;
20
21pub use keys::{Bindings, KeyCombo, Keymap};
22pub use theme::Theme;
23
24use crate::{action::ScreenId, mpd::Address, util::expand_tilde};
25
26pub const DEFAULT_CONFIG: &str = include_str!("default.toml");
27
28#[derive(Debug, thiserror::Error)]
29pub 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)]
47pub 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)]
56pub 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")]
63pub enum ColumnField {
64 Artist,
65 AlbumArtist,
66 Title,
67 Album,
68 Track,
69 Disc,
70 Date,
71 Genre,
72 Duration,
73 File,
74}
75
76impl 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)]
95pub struct Column {
96 pub field: ColumnField,
97 pub width: u16,
98}
99
100#[derive(Clone, Debug, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub 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)]
113pub 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)]
121pub 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)]
135struct 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)]
146pub 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
156impl Default for Config {
157 fn default() -> Self {
158 Self::from_toml(DEFAULT_CONFIG).expect("the built-in default configuration is valid")
159 }
160}
161
162impl 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.
305fn 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`.
318pub 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
328fn 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
348fn 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.
380fn 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)]
402mod 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}