Krolyxon
Commit ad32349

rmptuisrcconfig.rs

669 lines · 24.5 KB · Rust

1use std::{
2 collections::BTreeMap,
3 env,
4 error::Error,
5 fs::{self, OpenOptions},
6 io::{self, Write},
7 path::PathBuf,
8};
9
10use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11use serde::{Deserialize, Serialize};
12
13use crate::AppResult;
14
15#[derive(Clone, Debug, Default, Deserialize, Serialize)]
16#[serde(default)]
17pub struct Config {
18 pub mpd: MpdConfig,
19 pub playback: PlaybackConfig,
20 pub keybindings: Keybindings,
21}
22
23#[derive(Clone, Debug, Deserialize, Serialize)]
24#[serde(default)]
25pub struct MpdConfig {
26 pub host: String,
27 pub port: u16,
28 pub password: String,
29}
30
31impl Default for MpdConfig {
32 fn default() -> Self {
33 Self {
34 host: "127.0.0.1".to_string(),
35 port: 6600,
36 password: String::new(),
37 }
38 }
39}
40
41#[derive(Clone, Debug, Deserialize, Serialize)]
42#[serde(default)]
43pub struct PlaybackConfig {
44 pub seek_seconds: u64,
45 pub volume_step: u8,
46}
47
48impl Default for PlaybackConfig {
49 fn default() -> Self {
50 Self {
51 seek_seconds: 2,
52 volume_step: 2,
53 }
54 }
55}
56
57#[derive(Clone, Debug, Deserialize, Serialize)]
58#[serde(default)]
59pub struct Keybindings {
60 #[serde(flatten)]
61 bindings: BTreeMap<String, Vec<String>>,
62}
63
64impl Default for Keybindings {
65 fn default() -> Self {
66 let defaults: &[(&str, &[&str])] = &[
67 ("quit", &["q", "Ctrl+c"]),
68 ("clear_queue", &["c", "C"]),
69 ("toggle_pause", &["p"]),
70 ("pause", &["s"]),
71 ("toggle_repeat", &["r"]),
72 ("toggle_random", &["z"]),
73 ("open_playlist_menu", &["a"]),
74 ("seek_forward", &["f"]),
75 ("seek_backward", &["b"]),
76 ("cycle_tabs", &["Tab"]),
77 ("select_queue", &["1"]),
78 ("select_browser", &["2"]),
79 ("select_playlists", &["3"]),
80 ("next_track", &[">"]),
81 ("previous_track", &["<"]),
82 ("volume_up", &["=", "+"]),
83 ("volume_down", &["-"]),
84 ("toggle_mute", &["m"]),
85 ("update_database", &["U"]),
86 ("search", &["/"]),
87 ("toggle_selected_item", &["Space"]),
88 ("search_next", &["n"]),
89 ("search_previous", &["N"]),
90 ("move_down", &["j", "Down"]),
91 ("move_up", &["k", "Up"]),
92 ("activate", &["Enter", "l", "Right"]),
93 ("go_back", &["h", "Left"]),
94 ("go_to_top", &["g"]),
95 ("go_to_bottom", &["G"]),
96 ("queue_delete", &["d"]),
97 ("queue_move_down", &["J"]),
98 ("queue_move_up", &["K"]),
99 ("playlist_rename", &["R"]),
100 ("playlist_focus_contents", &["Right", "l"]),
101 ("playlist_focus_list", &["Left", "h"]),
102 ("playlist_delete_song", &["d"]),
103 ("playlist_move_song_down", &["J"]),
104 ("playlist_move_song_up", &["K"]),
105 ("playlist_delete", &["D"]),
106 ("playlist_load", &["Space", "Enter"]),
107 ("popup_close", &["q", "Esc"]),
108 ("popup_select", &["Enter"]),
109 ("input_cancel", &["Esc"]),
110 ("input_backspace", &["Backspace"]),
111 ("input_cursor_left", &["Left"]),
112 ("input_cursor_right", &["Right"]),
113 ("input_submit", &["Enter"]),
114 ];
115 Self {
116 bindings: defaults
117 .iter()
118 .map(|(action, keys)| {
119 (
120 (*action).to_string(),
121 keys.iter().map(|key| (*key).to_string()).collect(),
122 )
123 })
124 .collect(),
125 }
126 }
127}
128
129impl Keybindings {
130 pub fn matches(&self, action: &str, key: KeyEvent) -> bool {
131 self.bindings.get(action).is_some_and(|keys| {
132 keys.iter()
133 .any(|spec| parse_key_spec(spec).is_ok_and(|binding| key_matches(binding, key)))
134 })
135 }
136
137 fn validate(&self) -> AppResult<()> {
138 let defaults = Self::default();
139 for (action, keys) in &self.bindings {
140 if !defaults.bindings.contains_key(action) {
141 return Err(invalid_config(&format!(
142 "keybindings contains unknown action `{action}`"
143 )));
144 }
145 for key in keys {
146 parse_key_spec(key).map_err(|error| {
147 invalid_config(&format!(
148 "invalid keybinding `{key}` for `{action}`: {error}"
149 ))
150 })?;
151 }
152 }
153 Ok(())
154 }
155
156 fn apply_missing_defaults(&mut self) {
157 let defaults = Self::default().bindings;
158 for (action, keys) in &defaults {
159 if self
160 .bindings
161 .get(action)
162 .is_some_and(|current| current == &vec!["Right".to_string()])
163 && action == "playlist_focus_contents"
164 {
165 self.bindings.insert(action.clone(), keys.clone());
166 }
167 if self
168 .bindings
169 .get(action)
170 .is_some_and(|current| current == &vec!["Left".to_string()])
171 && action == "playlist_focus_list"
172 {
173 self.bindings.insert(action.clone(), keys.clone());
174 }
175 }
176 for (action, keys) in defaults {
177 self.bindings.entry(action).or_insert(keys);
178 }
179 }
180}
181
182impl Config {
183 pub fn load_or_create() -> AppResult<(Self, PathBuf)> {
184 let path = config_path()?;
185 let config = Self::load_or_create_at(&path)?;
186 Ok((config, path))
187 }
188
189 fn load_or_create_at(path: &std::path::Path) -> AppResult<Self> {
190 if let Some(parent) = path.parent() {
191 fs::create_dir_all(parent)?;
192 }
193
194 let contents = match fs::read_to_string(path) {
195 Ok(contents) => contents,
196 Err(error) if error.kind() == io::ErrorKind::NotFound => {
197 let default_contents = default_config_text()?;
198 match create_config_file(path, default_contents.as_bytes()) {
199 Ok(()) => default_contents,
200 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
201 fs::read_to_string(path)?
202 }
203 Err(error) => return Err(Box::new(error)),
204 }
205 }
206 Err(error) => return Err(Box::new(error)),
207 };
208
209 let mut config: Self = toml::from_str(&contents)?;
210 config.keybindings.apply_missing_defaults();
211 config.validate()?;
212 Ok(config)
213 }
214
215 fn validate(&self) -> AppResult<()> {
216 if self.mpd.host.trim().is_empty() {
217 return Err(invalid_config("mpd.host cannot be empty"));
218 }
219 if self.mpd.port == 0 {
220 return Err(invalid_config("mpd.port must be between 1 and 65535"));
221 }
222 if self.playback.seek_seconds == 0 {
223 return Err(invalid_config(
224 "playback.seek_seconds must be greater than zero",
225 ));
226 }
227 if !(1..=100).contains(&self.playback.volume_step) {
228 return Err(invalid_config(
229 "playback.volume_step must be between 1 and 100",
230 ));
231 }
232 self.keybindings.validate()
233 }
234}
235
236fn parse_key_spec(spec: &str) -> Result<KeyEvent, String> {
237 if spec == "+" {
238 return Ok(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty()));
239 }
240 let mut modifiers = KeyModifiers::empty();
241 let mut base = spec;
242 while !base.starts_with("Char(") {
243 let Some((modifier, rest)) = base.split_once('+') else {
244 break;
245 };
246 match modifier.to_ascii_lowercase().as_str() {
247 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
248 "alt" => modifiers |= KeyModifiers::ALT,
249 "shift" => modifiers |= KeyModifiers::SHIFT,
250 _ => return Err(format!("unknown modifier `{modifier}`")),
251 }
252 base = rest;
253 }
254 if base.is_empty() {
255 return Err("missing key".into());
256 }
257 let lowered_base = base.to_ascii_lowercase();
258 let code = if let Some(number) = lowered_base
259 .strip_prefix('f')
260 .and_then(|number| number.parse::<u8>().ok())
261 .filter(|number| (1..=24).contains(number))
262 {
263 KeyCode::F(number)
264 } else {
265 match lowered_base.as_str() {
266 "esc" | "escape" => KeyCode::Esc,
267 "enter" | "return" => KeyCode::Enter,
268 "tab" => KeyCode::Tab,
269 "backtab" => KeyCode::BackTab,
270 "backspace" => KeyCode::Backspace,
271 "delete" | "del" => KeyCode::Delete,
272 "insert" => KeyCode::Insert,
273 "up" => KeyCode::Up,
274 "down" => KeyCode::Down,
275 "left" => KeyCode::Left,
276 "right" => KeyCode::Right,
277 "home" => KeyCode::Home,
278 "end" => KeyCode::End,
279 "pageup" => KeyCode::PageUp,
280 "pagedown" => KeyCode::PageDown,
281 "space" => KeyCode::Char(' '),
282 _ if lowered_base.starts_with("char(") && base.ends_with(')') => {
283 let inner = &base[5..base.len() - 1];
284 let mut chars = inner.chars();
285 let character = chars
286 .next()
287 .ok_or_else(|| "empty Char(...) key".to_string())?;
288 if chars.next().is_some() {
289 return Err("Char(...) must contain exactly one character".into());
290 }
291 KeyCode::Char(character)
292 }
293 _ => {
294 let mut chars = base.chars();
295 let character = chars.next().ok_or_else(|| "missing key".to_string())?;
296 if chars.next().is_some() {
297 return Err(format!("unknown key `{base}`"));
298 }
299 KeyCode::Char(character)
300 }
301 }
302 };
303 Ok(KeyEvent::new(code, modifiers))
304}
305
306fn key_matches(binding: KeyEvent, event: KeyEvent) -> bool {
307 if binding.code != event.code {
308 return false;
309 }
310 let mut event_modifiers = event.modifiers;
311 if !binding.modifiers.contains(KeyModifiers::SHIFT) {
312 event_modifiers.remove(KeyModifiers::SHIFT);
313 }
314 binding.modifiers == event_modifiers
315}
316
317pub fn config_path() -> io::Result<PathBuf> {
318 let base = env::var_os("CONFIG_HOME")
319 .or_else(|| env::var_os("XDG_CONFIG_HOME"))
320 .map(PathBuf::from)
321 .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
322 .ok_or_else(|| {
323 io::Error::new(
324 io::ErrorKind::NotFound,
325 "CONFIG_HOME, XDG_CONFIG_HOME, and HOME are all unset",
326 )
327 })?;
328 if base.as_os_str().is_empty() {
329 return Err(io::Error::new(
330 io::ErrorKind::InvalidInput,
331 "configuration home environment variable cannot be empty",
332 ));
333 }
334 Ok(base.join("rmptui").join("config.toml"))
335}
336
337fn default_config_text() -> AppResult<String> {
338 let serialized = toml::to_string_pretty(&Config::default())?;
339 let mut documented = String::from(
340 "# rmptui configuration. Values below are the built-in defaults.\n\
341 # Save changes and restart rmptui for them to take effect.\n\
342 #\n\
343 # Config file location: $CONFIG_HOME/rmptui/config.toml, falling back to\n\
344 # $XDG_CONFIG_HOME/rmptui/config.toml, then $HOME/.config/rmptui/config.toml.\n\
345 #\n\
346 # Environment overrides:\n\
347 # MPD_HOST overrides [mpd].host. It may include a port (host:port),\n\
348 # or credentials as password@host. A port embedded in MPD_HOST wins\n\
349 # over MPD_PORT.\n\
350 # MPD_PORT overrides [mpd].port when MPD_HOST has no embedded port.\n\
351 #\n\
352 # Key names may be characters or named keys (Enter, Esc, Tab, Space,\n\
353 # arrows, Backspace, F1-F24). Prefix modifiers with Ctrl+, Alt+, or\n\
354 # Shift+. Use arrays for aliases and [] to disable an action.\n\
355 # The password is stored as plain text; this file is created with\n\
356 # owner-only permissions on Unix.\n\n",
357 );
358
359 let mut section = "";
360 for line in serialized.lines() {
361 if line.starts_with('[') && line.ends_with(']') {
362 section = &line[1..line.len() - 1];
363 let description = match section {
364 "mpd" => "# MPD server connection settings.",
365 "playback" => "# Playback adjustment amounts.",
366 "keybindings" => "# Action keys, matched only in relevant UI contexts.",
367 _ => "# Configuration options.",
368 };
369 documented.push_str(description);
370 documented.push('\n');
371 documented.push_str(line);
372 documented.push('\n');
373 continue;
374 }
375
376 if let Some((key, _)) = line.split_once(" = ") {
377 let description = option_description(section, key);
378 documented.push_str("# ");
379 documented.push_str(description);
380 documented.push('\n');
381 }
382 documented.push_str(line);
383 documented.push('\n');
384 }
385
386 Ok(documented)
387}
388
389fn option_description(section: &str, key: &str) -> &'static str {
390 match (section, key) {
391 ("mpd", "host") => "MPD hostname or address. MPD_HOST overrides this value.",
392 ("mpd", "port") => "MPD TCP port. MPD_PORT overrides it unless MPD_HOST includes a port.",
393 ("mpd", "password") => {
394 "Optional MPD password; leave empty when the server has no password."
395 }
396 ("playback", "seek_seconds") => "Seconds moved by seek_forward and seek_backward.",
397 ("playback", "volume_step") => {
398 "Volume percentage changed by volume_up and volume_down (1-100)."
399 }
400 ("keybindings", "quit") => "Exit rmptui.",
401 ("keybindings", "clear_queue") => "Remove every song from the current play queue.",
402 ("keybindings", "toggle_pause") => "Toggle playback between paused and playing.",
403 ("keybindings", "pause") => "Pause playback.",
404 ("keybindings", "toggle_repeat") => "Toggle repeat mode.",
405 ("keybindings", "toggle_random") => "Toggle random playback mode.",
406 ("keybindings", "open_playlist_menu") => {
407 "Open the menu for adding the selection to a playlist."
408 }
409 ("keybindings", "seek_forward") => "Seek forward by playback.seek_seconds.",
410 ("keybindings", "seek_backward") => "Seek backward by playback.seek_seconds.",
411 ("keybindings", "cycle_tabs") => "Cycle through Queue, Browser, and Playlists views.",
412 ("keybindings", "select_queue") => "Open the Queue view.",
413 ("keybindings", "select_browser") => "Open the Browser view.",
414 ("keybindings", "select_playlists") => "Open the Playlists view.",
415 ("keybindings", "next_track") => "Start the next track in the queue.",
416 ("keybindings", "previous_track") => "Start the previous track in the queue.",
417 ("keybindings", "volume_up") => "Increase volume by playback.volume_step.",
418 ("keybindings", "volume_down") => "Decrease volume by playback.volume_step.",
419 ("keybindings", "toggle_mute") => "Toggle mute.",
420 ("keybindings", "update_database") => "Ask MPD to rescan its music database.",
421 ("keybindings", "search") => "Start fuzzy search in the current list.",
422 ("keybindings", "toggle_selected_item") => {
423 "Toggle the selected song in the queue or browser."
424 }
425 ("keybindings", "search_next") => "Select the next browser fuzzy-search match.",
426 ("keybindings", "search_previous") => "Select the previous browser fuzzy-search match.",
427 ("keybindings", "move_down") => "Move selection down in the focused list.",
428 ("keybindings", "move_up") => "Move selection up in the focused list.",
429 ("keybindings", "activate") => {
430 "Activate the focused item (for example, play a song or enter a directory)."
431 }
432 ("keybindings", "go_back") => "Go to the parent directory in the Browser view.",
433 ("keybindings", "go_to_top") => "Select the first item in the focused list.",
434 ("keybindings", "go_to_bottom") => "Select the last item in the focused list.",
435 ("keybindings", "queue_delete") => "Remove the selected song from the current queue.",
436 ("keybindings", "queue_move_down") => "Move the selected queue song down one position.",
437 ("keybindings", "queue_move_up") => "Move the selected queue song up one position.",
438 ("keybindings", "playlist_rename") => "Rename the selected saved playlist.",
439 ("keybindings", "playlist_focus_contents") => {
440 "Focus the selected playlist's song contents."
441 }
442 ("keybindings", "playlist_focus_list") => "Return focus to the saved-playlist list.",
443 ("keybindings", "playlist_delete_song") => {
444 "Delete the selected song from the saved playlist."
445 }
446 ("keybindings", "playlist_move_song_down") => {
447 "Move the selected saved-playlist song down one position."
448 }
449 ("keybindings", "playlist_move_song_up") => {
450 "Move the selected saved-playlist song up one position."
451 }
452 ("keybindings", "playlist_delete") => "Delete the selected saved playlist.",
453 ("keybindings", "playlist_load") => {
454 "Append the selected saved playlist to the queue and start playback."
455 }
456 ("keybindings", "popup_close") => "Close the add-to-playlist menu.",
457 ("keybindings", "popup_select") => {
458 "Choose the selected destination in the add-to-playlist menu."
459 }
460 ("keybindings", "input_cancel") => "Cancel the active search or playlist-name input.",
461 ("keybindings", "input_backspace") => "Delete the character before the input cursor.",
462 ("keybindings", "input_cursor_left") => "Move the input cursor left.",
463 ("keybindings", "input_cursor_right") => "Move the input cursor right.",
464 ("keybindings", "input_submit") => "Submit the active search or playlist-name input.",
465 _ => "Key aliases for this action.",
466 }
467}
468
469fn create_config_file(path: &std::path::Path, contents: &[u8]) -> io::Result<()> {
470 let mut options = OpenOptions::new();
471 options.write(true).create_new(true);
472 #[cfg(unix)]
473 {
474 use std::os::unix::fs::OpenOptionsExt;
475 options.mode(0o600);
476 }
477 let mut file = options.open(path)?;
478 file.write_all(contents)
479}
480
481fn invalid_config(message: &str) -> Box<dyn Error + Send + Sync> {
482 Box::new(io::Error::new(
483 io::ErrorKind::InvalidData,
484 message.to_string(),
485 ))
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn default_config_round_trips_with_all_sections() {
494 let config = Config::default();
495 let serialized = toml::to_string_pretty(&config).unwrap();
496 let parsed: Config = toml::from_str(&serialized).unwrap();
497
498 assert_eq!(parsed.mpd.host, "127.0.0.1");
499 assert_eq!(parsed.mpd.port, 6600);
500 assert_eq!(parsed.playback.seek_seconds, 2);
501 assert_eq!(parsed.playback.volume_step, 2);
502 assert!(parsed.keybindings.matches(
503 "quit",
504 KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())
505 ));
506 assert!(parsed.keybindings.matches(
507 "quit",
508 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
509 ));
510 }
511
512 #[test]
513 fn generated_default_config_is_toml_and_has_helpful_comments() {
514 let contents = default_config_text().unwrap();
515 let parsed: Config = toml::from_str(&contents).unwrap();
516 assert_eq!(parsed.mpd.host, "127.0.0.1");
517 assert!(contents.starts_with("# rmptui configuration."));
518 assert!(contents.contains("password = \"\""));
519 assert!(contents.contains("[keybindings]"));
520 assert!(contents.contains("input_submit = [\"Enter\"]"));
521 assert!(contents.contains("MPD_HOST overrides [mpd].host"));
522 assert!(contents.contains("MPD_PORT overrides"));
523 assert!(contents.contains("# Focus the selected playlist's song contents."));
524 assert!(contents
525 .contains("# Append the selected saved playlist to the queue and start playback."));
526 assert!(contents.contains("playlist_focus_contents = ["));
527 for action in Keybindings::default().bindings.keys() {
528 assert_ne!(
529 option_description("keybindings", action),
530 "Key aliases for this action.",
531 "missing generated-config documentation for `{action}`"
532 );
533 }
534 }
535
536 #[test]
537 fn creates_default_config_and_reads_user_changes() {
538 let unique = format!(
539 "rmptui-config-test-{}-{}",
540 std::process::id(),
541 std::time::SystemTime::now()
542 .duration_since(std::time::UNIX_EPOCH)
543 .unwrap()
544 .as_nanos()
545 );
546 let directory = env::temp_dir().join(unique);
547 let path = directory.join("rmptui").join("config.toml");
548
549 let created = Config::load_or_create_at(&path).unwrap();
550 assert_eq!(created.playback.volume_step, 2);
551 let contents = fs::read_to_string(&path).unwrap();
552 assert!(contents.contains("password = \"\""));
553
554 fs::write(
555 &path,
556 contents.replace("volume_step = 2", "volume_step = 8"),
557 )
558 .unwrap();
559 let loaded = Config::load_or_create_at(&path).unwrap();
560 assert_eq!(loaded.playback.volume_step, 8);
561
562 fs::remove_dir_all(directory).unwrap();
563 }
564
565 #[test]
566 fn partial_configuration_uses_defaults_for_missing_values() {
567 let parsed: Config = toml::from_str(
568 r#"
569 [mpd]
570 host = "music.local"
571 port = 6601
572 "#,
573 )
574 .unwrap();
575
576 assert_eq!(parsed.mpd.host, "music.local");
577 assert_eq!(parsed.mpd.port, 6601);
578 assert_eq!(parsed.playback.seek_seconds, 2);
579 assert_eq!(
580 parsed.keybindings.bindings.len(),
581 Keybindings::default().bindings.len()
582 );
583 }
584
585 #[test]
586 fn keybinding_parser_supports_aliases_modifiers_and_punctuation() {
587 let bindings = Keybindings::default();
588 assert!(bindings.matches(
589 "volume_up",
590 KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty())
591 ));
592 assert!(bindings.matches(
593 "move_down",
594 KeyEvent::new(KeyCode::Down, KeyModifiers::empty())
595 ));
596 assert!(bindings.matches(
597 "quit",
598 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
599 ));
600 assert!(bindings.matches(
601 "search_previous",
602 KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT)
603 ));
604 assert!(parse_key_spec("Ctrl+Char(+)").is_ok());
605 assert_eq!(parse_key_spec("F12").unwrap().code, KeyCode::F(12));
606 }
607
608 #[test]
609 fn partial_keybinding_configuration_keeps_other_defaults() {
610 let mut parsed: Config = toml::from_str(
611 r#"
612 [keybindings]
613 quit = ["x"]
614 "#,
615 )
616 .unwrap();
617 parsed.keybindings.apply_missing_defaults();
618
619 assert!(parsed.keybindings.matches(
620 "quit",
621 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())
622 ));
623 assert!(parsed.keybindings.matches(
624 "move_down",
625 KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty())
626 ));
627 }
628
629 #[test]
630 fn playlist_focus_defaults_migrate_with_existing_default_config() {
631 let mut parsed: Config = toml::from_str(
632 r#"
633 [keybindings]
634 playlist_focus_contents = ["Right"]
635 playlist_focus_list = ["Left"]
636 "#,
637 )
638 .unwrap();
639 parsed.keybindings.apply_missing_defaults();
640
641 assert_eq!(
642 parsed.keybindings.bindings["playlist_focus_contents"],
643 vec!["Right", "l"]
644 );
645 assert_eq!(
646 parsed.keybindings.bindings["playlist_focus_list"],
647 vec!["Left", "h"]
648 );
649 assert_eq!(
650 parsed.keybindings.bindings["playlist_load"],
651 vec!["Space", "Enter"]
652 );
653 }
654
655 #[test]
656 fn invalid_keybinding_actions_and_keys_are_rejected() {
657 let mut bindings = Keybindings::default();
658 bindings
659 .bindings
660 .insert("unknown_action".to_string(), vec!["x".to_string()]);
661 assert!(bindings.validate().is_err());
662
663 let mut bindings = Keybindings::default();
664 bindings
665 .bindings
666 .insert("quit".to_string(), vec!["not-a-key".to_string()]);
667 assert!(bindings.validate().is_err());
668 }
669}