1use anyhow::{Context, Result, anyhow};
2
3use serde::{Deserialize, Deserializer, de};
4
5use std::{fmt, path::PathBuf, str::FromStr};
6
7#[derive(Debug, Default, Deserialize)]
36#[serde(default)]
37pub struct Config {
38 pub format: Option<crate::ByteFormat>,
40
41 pub keys: KeysConfig,
43
44 pub notifications: NotificationsConfig,
46
47 pub gitignore: Option<bool>,
52
53 pub cleanup_heuristics: Option<bool>,
58}
59
60#[derive(Debug, Deserialize)]
62#[serde(default)]
63pub struct NotificationsConfig {
64 pub scan_finished: bool,
66 pub delete_finished: bool,
68}
69
70impl Default for NotificationsConfig {
71 fn default() -> Self {
72 Self {
73 scan_finished: true,
74 delete_finished: true,
75 }
76 }
77}
78
79impl NotificationsConfig {
80 pub fn any_enabled(&self) -> bool {
82 self.scan_finished || self.delete_finished
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct KeyBindings(Vec<KeyBinding>);
89
90const UNMAPPED_KEY: &str = "<unmapped>";
91
92impl KeyBindings {
93 fn defaults(bindings: &[&str]) -> Self {
94 Self(
95 bindings
96 .iter()
97 .map(|binding| binding.parse().expect("valid built-in keybinding"))
98 .collect(),
99 )
100 }
101
102 #[cfg(feature = "tui-crossplatform")]
104 #[must_use]
105 pub fn matches(&self, key: crossterm::event::KeyEvent) -> bool {
106 self.0.iter().any(|binding| binding.matches(key))
107 }
108
109 #[must_use]
111 pub fn primary(&self) -> String {
112 self.0
113 .first()
114 .map_or_else(|| UNMAPPED_KEY.into(), ToString::to_string)
115 }
116
117 #[must_use]
119 pub fn is_empty(&self) -> bool {
120 self.0.is_empty()
121 }
122}
123
124impl fmt::Display for KeyBindings {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 if self.0.is_empty() {
127 return f.write_str(UNMAPPED_KEY);
128 }
129 for (index, binding) in self.0.iter().enumerate() {
130 if index != 0 {
131 f.write_str("/")?;
132 }
133 binding.fmt(f)?;
134 }
135 Ok(())
136 }
137}
138
139#[derive(Deserialize)]
140#[serde(untagged)]
141enum KeyBindingsRepr {
142 One(String),
143 Many(Vec<String>),
144}
145
146impl<'de> Deserialize<'de> for KeyBindings {
147 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
148 where
149 D: Deserializer<'de>,
150 {
151 match KeyBindingsRepr::deserialize(deserializer)? {
152 KeyBindingsRepr::One(binding) => vec![binding],
153 KeyBindingsRepr::Many(bindings) => bindings,
154 }
155 .into_iter()
156 .map(|binding| binding.parse().map_err(de::Error::custom))
157 .collect::<Result<Vec<_>, _>>()
158 .map(Self)
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct KeyBinding {
165 code: Key,
166 modifiers: Vec<Modifier>,
167}
168
169impl FromStr for KeyBinding {
170 type Err = String;
171
172 fn from_str(binding: &str) -> Result<Self, Self::Err> {
173 if binding.is_empty() {
174 return Err("keybinding cannot be empty".into());
175 }
176
177 let mut parts = if binding == "+" {
178 vec![binding]
179 } else {
180 binding.split('+').map(str::trim).collect::<Vec<_>>()
181 };
182 let key = parts
183 .pop()
184 .filter(|key| !key.is_empty())
185 .ok_or_else(|| format!("keybinding '{binding}' has no key"))?;
186 let mut modifiers = Vec::new();
187 for modifier in parts {
188 let modifier = match modifier.to_ascii_lowercase().as_str() {
189 "ctrl" | "control" => Modifier::Control,
190 "alt" => Modifier::Alt,
191 "shift" => Modifier::Shift,
192 _ => {
193 return Err(format!(
194 "unknown modifier '{modifier}' in keybinding '{binding}'"
195 ));
196 }
197 };
198 if !modifiers.contains(&modifier) {
199 modifiers.push(modifier);
200 }
201 }
202
203 let code = Key::from_str(key).map_err(|err| format!("{err} in keybinding '{binding}'"))?;
204 Ok(Self { code, modifiers })
205 }
206}
207
208impl fmt::Display for KeyBinding {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 for modifier in &self.modifiers {
211 write!(f, "{modifier} + ")?;
212 }
213 self.code.fmt(f)
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum Key {
219 Char(char),
220 Backspace,
221 Enter,
222 Left,
223 Right,
224 Up,
225 Down,
226 Home,
227 End,
228 PageUp,
229 PageDown,
230 Tab,
231 BackTab,
232 Delete,
233 Insert,
234 Function(u8),
235 Null,
236 Esc,
237 CapsLock,
238 ScrollLock,
239 NumLock,
240 PrintScreen,
241 Pause,
242 Menu,
243 KeypadBegin,
244}
245
246impl FromStr for Key {
247 type Err = String;
248
249 fn from_str(key: &str) -> Result<Self, Self::Err> {
250 if key.chars().count() == 1 {
251 return Ok(Self::Char(key.chars().next().expect("one character")));
252 }
253
254 let normalized = key.to_ascii_lowercase();
255 let key = match normalized.as_str() {
256 "space" => Self::Char(' '),
257 "plus" => Self::Char('+'),
258 "backspace" => Self::Backspace,
259 "enter" | "return" => Self::Enter,
260 "left" => Self::Left,
261 "right" => Self::Right,
262 "up" => Self::Up,
263 "down" => Self::Down,
264 "home" => Self::Home,
265 "end" => Self::End,
266 "page-up" | "pageup" => Self::PageUp,
267 "page-down" | "pagedown" => Self::PageDown,
268 "tab" => Self::Tab,
269 "back-tab" | "backtab" => Self::BackTab,
270 "delete" | "del" => Self::Delete,
271 "insert" => Self::Insert,
272 "null" => Self::Null,
273 "esc" | "escape" => Self::Esc,
274 "caps-lock" => Self::CapsLock,
275 "scroll-lock" => Self::ScrollLock,
276 "num-lock" => Self::NumLock,
277 "print-screen" => Self::PrintScreen,
278 "pause" => Self::Pause,
279 "menu" => Self::Menu,
280 "keypad-begin" => Self::KeypadBegin,
281 function if function.starts_with('f') => {
282 let number = function[1..]
283 .parse()
284 .map_err(|_| format!("unknown key '{key}'"))?;
285 if number == 0 {
286 return Err(format!("unknown key '{key}'"));
287 }
288 Self::Function(number)
289 }
290 _ => return Err(format!("unknown key '{key}'")),
291 };
292 Ok(key)
293 }
294}
295
296impl fmt::Display for Key {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 match self {
299 Self::Char(' ') => f.write_str("<Space>"),
300 Self::Char(character) => character.fmt(f),
301 Self::Backspace => f.write_str("<Backspace>"),
302 Self::Enter => f.write_str("<Enter>"),
303 Self::Left => f.write_str("<Left>"),
304 Self::Right => f.write_str("<Right>"),
305 Self::Up => f.write_str("<Up>"),
306 Self::Down => f.write_str("<Down>"),
307 Self::Home => f.write_str("<Home>"),
308 Self::End => f.write_str("<End>"),
309 Self::PageUp => f.write_str("<Page Up>"),
310 Self::PageDown => f.write_str("<Page Down>"),
311 Self::Tab => f.write_str("<Tab>"),
312 Self::BackTab => f.write_str("<Back Tab>"),
313 Self::Delete => f.write_str("<Delete>"),
314 Self::Insert => f.write_str("<Insert>"),
315 Self::Function(number) => write!(f, "<F{number}>"),
316 Self::Null => f.write_str("<Null>"),
317 Self::Esc => f.write_str("<Esc>"),
318 Self::CapsLock => f.write_str("<Caps Lock>"),
319 Self::ScrollLock => f.write_str("<Scroll Lock>"),
320 Self::NumLock => f.write_str("<Num Lock>"),
321 Self::PrintScreen => f.write_str("<Print Screen>"),
322 Self::Pause => f.write_str("<Pause>"),
323 Self::Menu => f.write_str("<Menu>"),
324 Self::KeypadBegin => f.write_str("<Keypad Begin>"),
325 }
326 }
327}
328
329#[cfg(feature = "tui-crossplatform")]
330impl Key {
331 fn to_crossterm(self) -> crossterm::event::KeyCode {
332 use crossterm::event::KeyCode;
333
334 match self {
335 Self::Char(character) => KeyCode::Char(character),
336 Self::Backspace => KeyCode::Backspace,
337 Self::Enter => KeyCode::Enter,
338 Self::Left => KeyCode::Left,
339 Self::Right => KeyCode::Right,
340 Self::Up => KeyCode::Up,
341 Self::Down => KeyCode::Down,
342 Self::Home => KeyCode::Home,
343 Self::End => KeyCode::End,
344 Self::PageUp => KeyCode::PageUp,
345 Self::PageDown => KeyCode::PageDown,
346 Self::Tab => KeyCode::Tab,
347 Self::BackTab => KeyCode::BackTab,
348 Self::Delete => KeyCode::Delete,
349 Self::Insert => KeyCode::Insert,
350 Self::Function(number) => KeyCode::F(number),
351 Self::Null => KeyCode::Null,
352 Self::Esc => KeyCode::Esc,
353 Self::CapsLock => KeyCode::CapsLock,
354 Self::ScrollLock => KeyCode::ScrollLock,
355 Self::NumLock => KeyCode::NumLock,
356 Self::PrintScreen => KeyCode::PrintScreen,
357 Self::Pause => KeyCode::Pause,
358 Self::Menu => KeyCode::Menu,
359 Self::KeypadBegin => KeyCode::KeypadBegin,
360 }
361 }
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365enum Modifier {
366 Control,
367 Alt,
368 Shift,
369}
370
371impl fmt::Display for Modifier {
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 f.write_str(match self {
374 Self::Control => "Ctrl",
375 Self::Alt => "Alt",
376 Self::Shift => "Shift",
377 })
378 }
379}
380
381#[cfg(feature = "tui-crossplatform")]
382impl KeyBinding {
383 #[must_use]
385 pub fn to_event(&self) -> crossterm::event::KeyEvent {
386 use crossterm::event::{KeyEvent, KeyModifiers};
387
388 let modifiers = self
389 .modifiers
390 .iter()
391 .fold(KeyModifiers::NONE, |modifiers, modifier| {
392 modifiers
393 | match modifier {
394 Modifier::Control => KeyModifiers::CONTROL,
395 Modifier::Alt => KeyModifiers::ALT,
396 Modifier::Shift => KeyModifiers::SHIFT,
397 }
398 });
399 KeyEvent::new(self.code.to_crossterm(), modifiers)
400 }
401
402 fn matches(&self, key: crossterm::event::KeyEvent) -> bool {
403 use crossterm::event::{KeyEvent, KeyModifiers};
404
405 let mut key_modifiers = key.modifiers;
406 if !self.modifiers.contains(&Modifier::Shift)
407 && matches!(self.code, Key::Char(character) if !character.is_alphanumeric())
408 {
409 key_modifiers.remove(KeyModifiers::SHIFT);
410 }
411 self.to_event() == KeyEvent::new(key.code, key_modifiers)
412 }
413}
414
415#[derive(Debug, Deserialize, PartialEq, Eq)]
417#[serde(default)]
418pub struct KeysConfig {
419 #[serde(default = "default_esc_navigates_back")]
426 pub esc_navigates_back: bool,
427
428 pub close_pane: KeyBindings,
430 pub quit: KeyBindings,
432 pub quit_immediately: KeyBindings,
434 pub suspend: KeyBindings,
436 pub cycle_panes: KeyBindings,
438 pub toggle_help: KeyBindings,
440 pub open_search: KeyBindings,
442 pub move_down: KeyBindings,
444 pub move_up: KeyBindings,
446 pub page_down: KeyBindings,
448 pub page_up: KeyBindings,
450 pub move_to_top: KeyBindings,
452 pub move_to_bottom: KeyBindings,
454 pub descend: KeyBindings,
456 pub ascend: KeyBindings,
458 pub scan_parent: KeyBindings,
460 pub sort_by_size: KeyBindings,
462 pub sort_by_mtime: KeyBindings,
464 pub cycle_mtime_mode: KeyBindings,
466 pub sort_by_count: KeyBindings,
468 pub toggle_count_column: KeyBindings,
470 pub sort_by_name: KeyBindings,
472 pub cycle_visualization: KeyBindings,
474 pub open_entry: KeyBindings,
476 pub toggle_mark: KeyBindings,
478 pub mark_for_deletion: KeyBindings,
480 pub toggle_mark_and_move_down: KeyBindings,
482 pub toggle_all: KeyBindings,
484 pub toggle_cleanup: KeyBindings,
486 pub mark_cleanup: KeyBindings,
488 pub toggle_gitignore: KeyBindings,
490 pub mark_gitignore: KeyBindings,
492 pub refresh_selected: KeyBindings,
494 pub refresh_all: KeyBindings,
496 pub remove_mark: KeyBindings,
498 pub remove_all_marks: KeyBindings,
500 pub delete_marked: KeyBindings,
502 pub trash_marked: KeyBindings,
504 pub search_confirm: KeyBindings,
506 pub search_toggle_case: KeyBindings,
508 pub search_backspace: KeyBindings,
510 pub search_left: KeyBindings,
512 pub search_right: KeyBindings,
514}
515
516fn default_esc_navigates_back() -> bool {
517 true
518}
519
520impl Default for KeysConfig {
521 fn default() -> Self {
522 Self {
523 esc_navigates_back: default_esc_navigates_back(),
524 close_pane: KeyBindings::defaults(&["esc"]),
525 quit: KeyBindings::defaults(&["q"]),
526 quit_immediately: KeyBindings::defaults(&["ctrl+c"]),
527 suspend: KeyBindings::defaults(&["ctrl+z"]),
528 cycle_panes: KeyBindings::defaults(&["tab"]),
529 toggle_help: KeyBindings::defaults(&["?"]),
530 open_search: KeyBindings::defaults(&["/"]),
531 move_down: KeyBindings::defaults(&["j", "down"]),
532 move_up: KeyBindings::defaults(&["k", "up"]),
533 page_down: KeyBindings::defaults(&["ctrl+d", "page-down"]),
534 page_up: KeyBindings::defaults(&["ctrl+u", "page-up"]),
535 move_to_top: KeyBindings::defaults(&["H", "home"]),
536 move_to_bottom: KeyBindings::defaults(&["G", "end"]),
537 descend: KeyBindings::defaults(&["o", "l", "enter", "right"]),
538 ascend: KeyBindings::defaults(&["u", "h", "backspace", "left"]),
539 scan_parent: KeyBindings::defaults(&["U"]),
540 sort_by_size: KeyBindings::defaults(&["s"]),
541 sort_by_mtime: KeyBindings::defaults(&["m"]),
542 cycle_mtime_mode: KeyBindings::defaults(&["M"]),
543 sort_by_count: KeyBindings::defaults(&["c"]),
544 toggle_count_column: KeyBindings::defaults(&["C"]),
545 sort_by_name: KeyBindings::defaults(&["n"]),
546 cycle_visualization: KeyBindings::defaults(&["g", "S"]),
547 open_entry: KeyBindings::defaults(&["O"]),
548 toggle_mark: KeyBindings::defaults(&["space"]),
549 mark_for_deletion: KeyBindings::defaults(&["x"]),
550 toggle_mark_and_move_down: KeyBindings::defaults(&["d"]),
551 toggle_all: KeyBindings::defaults(&["a"]),
552 toggle_cleanup: KeyBindings::defaults(&["t"]),
553 mark_cleanup: KeyBindings::defaults(&["X"]),
554 toggle_gitignore: KeyBindings::defaults(&["i"]),
555 mark_gitignore: KeyBindings::defaults(&["I"]),
556 refresh_selected: KeyBindings::defaults(&["r"]),
557 refresh_all: KeyBindings::defaults(&["R"]),
558 remove_mark: KeyBindings::defaults(&["x", "d", "space"]),
559 remove_all_marks: KeyBindings::defaults(&["a"]),
560 delete_marked: KeyBindings::defaults(&["ctrl+r"]),
561 trash_marked: KeyBindings::defaults(&["ctrl+t"]),
562 search_confirm: KeyBindings::defaults(&["enter"]),
563 search_toggle_case: KeyBindings::defaults(&["ctrl+f"]),
564 search_backspace: KeyBindings::defaults(&["backspace"]),
565 search_left: KeyBindings::defaults(&["left"]),
566 search_right: KeyBindings::defaults(&["right"]),
567 }
568 }
569}
570
571impl Config {
572 pub fn load() -> Result<Self> {
582 let Ok(path) = Self::path() else {
583 log::info!("Configuration path couldn't be determined. Using defaults.");
584 return Ok(Config::default());
585 };
586
587 let contents = match std::fs::read_to_string(&path) {
588 Ok(c) => c,
589 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
590 log::info!(
591 "Configuration not loaded from {}: file not found. Using defaults.",
592 path.display()
593 );
594 return Ok(Config::default());
595 }
596 Err(e) => {
597 return Err(e)
598 .with_context(|| format!("Failed to read config at {}", path.display()));
599 }
600 };
601
602 toml::from_str(&contents)
603 .with_context(|| format!("Failed to parse config at {}", path.display()))
604 }
605
606 #[must_use]
608 pub fn default_file_content() -> &'static str {
609 concat!(
610 "# dua-cli configuration\n",
611 "#\n",
612 "# Byte count format to use when --format and DUA_FORMAT are not set.\n",
613 "# Supported values: metric, binary, bytes, gb, gib, mb, mib.\n",
614 "# format = \"binary\"\n",
615 "#\n",
616 "# Controls whether Git-ignored entry detection is enabled in interactive mode.\n",
617 "# Supported values: true, false.\n",
618 "# If unset, behavior defaults to true.\n",
619 "# gitignore = true\n",
620 "#\n",
621 "# Controls whether cleanup heuristics are enabled in interactive mode.\n",
622 "# Supported values: true, false.\n",
623 "# If unset, behavior defaults to true.\n",
624 "# cleanup_heuristics = true\n",
625 "#\n",
626 "[keys]\n",
627 "# If true, close_pane keys ascend from the main pane.\n",
628 "# If false, close_pane keys follow the quit behavior.\n",
629 "esc_navigates_back = true\n",
630 "#\n",
631 "# Use a string for one binding or an array for aliases. Uncomment to replace the defaults.\n",
632 "# Modifiers: ctrl, alt, shift. Named keys include\n",
633 "# esc, enter, space, tab, backspace, arrows, home, end, page-up, page-down, and F1-F255.\n",
634 "# Character keys are case-sensitive; use [] to disable an action.\n",
635 "#\n",
636 "# Pane and application control.\n",
637 "# close_pane = \"esc\"\n",
638 "# quit = \"q\"\n",
639 "# quit_immediately = \"ctrl+c\"\n",
640 "# suspend = \"ctrl+z\" # Unix only.\n",
641 "# cycle_panes = \"tab\"\n",
642 "# toggle_help = \"?\"\n",
643 "# open_search = \"/\"\n",
644 "#\n",
645 "# Navigation.\n",
646 "# move_down = [\"j\", \"down\"]\n",
647 "# move_up = [\"k\", \"up\"]\n",
648 "# page_down = [\"ctrl+d\", \"page-down\"]\n",
649 "# page_up = [\"ctrl+u\", \"page-up\"]\n",
650 "# move_to_top = [\"H\", \"home\"]\n",
651 "# move_to_bottom = [\"G\", \"end\"]\n",
652 "# descend = [\"o\", \"l\", \"enter\", \"right\"]\n",
653 "# ascend = [\"u\", \"h\", \"backspace\", \"left\"]\n",
654 "# scan_parent = \"U\"\n",
655 "#\n",
656 "# Display.\n",
657 "# sort_by_size = \"s\"\n",
658 "# sort_by_mtime = \"m\"\n",
659 "# cycle_mtime_mode = \"M\"\n",
660 "# sort_by_count = \"c\"\n",
661 "# toggle_count_column = \"C\"\n",
662 "# sort_by_name = \"n\"\n",
663 "# cycle_visualization = [\"g\", \"S\"]\n",
664 "#\n",
665 "# Entry actions.\n",
666 "# open_entry = \"O\"\n",
667 "# toggle_mark = \"space\"\n",
668 "# mark_for_deletion = \"x\"\n",
669 "# toggle_mark_and_move_down = \"d\"\n",
670 "# toggle_all = \"a\"\n",
671 "# toggle_cleanup = \"t\"\n",
672 "# mark_cleanup = \"X\"\n",
673 "# toggle_gitignore = \"i\"\n",
674 "# mark_gitignore = \"I\"\n",
675 "# refresh_selected = \"r\"\n",
676 "# refresh_all = \"R\"\n",
677 "#\n",
678 "# Marked-items pane.\n",
679 "# remove_mark = [\"x\", \"d\", \"space\"]\n",
680 "# remove_all_marks = \"a\"\n",
681 "# delete_marked = \"ctrl+r\"\n",
682 "# trash_marked = \"ctrl+t\"\n",
683 "#\n",
684 "# Search pane.\n",
685 "# search_confirm = \"enter\"\n",
686 "# search_toggle_case = \"ctrl+f\"\n",
687 "# search_backspace = \"backspace\"\n",
688 "# search_left = \"left\"\n",
689 "# search_right = \"right\"\n",
690 "#\n",
691 "[notifications]\n",
692 "# Send terminal notifications when interactive operations finish while unfocused.\n",
693 "scan_finished = true\n",
694 "delete_finished = true\n",
695 )
696 }
697
698 pub fn path() -> Result<PathBuf> {
707 let config_dir = dirs::config_dir()
710 .ok_or_else(|| anyhow!("platform config directory is unavailable"))?;
711 Ok(config_dir.join("dua-cli").join("config.toml"))
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::Config;
718
719 #[test]
720 fn keybindings_keep_current_defaults_and_are_documented() {
721 let expected_actions = [
722 "close_pane",
723 "quit",
724 "quit_immediately",
725 "suspend",
726 "cycle_panes",
727 "toggle_help",
728 "open_search",
729 "move_down",
730 "move_up",
731 "page_down",
732 "page_up",
733 "move_to_top",
734 "move_to_bottom",
735 "descend",
736 "ascend",
737 "scan_parent",
738 "sort_by_size",
739 "sort_by_mtime",
740 "cycle_mtime_mode",
741 "sort_by_count",
742 "toggle_count_column",
743 "sort_by_name",
744 "cycle_visualization",
745 "open_entry",
746 "toggle_mark",
747 "mark_for_deletion",
748 "toggle_mark_and_move_down",
749 "toggle_all",
750 "toggle_cleanup",
751 "mark_cleanup",
752 "toggle_gitignore",
753 "mark_gitignore",
754 "refresh_selected",
755 "refresh_all",
756 "remove_mark",
757 "remove_all_marks",
758 "delete_marked",
759 "trash_marked",
760 "search_confirm",
761 "search_toggle_case",
762 "search_backspace",
763 "search_left",
764 "search_right",
765 ];
766 let defaults = Config::default_file_content();
767
768 for action in expected_actions {
769 assert!(
770 defaults
771 .lines()
772 .any(|line| line.starts_with(&format!("# {action} = "))),
773 "missing default template for keys.{action}"
774 );
775 }
776
777 let configured: Config = toml::from_str(&format!(
778 "[keys]\n{}",
779 defaults
780 .lines()
781 .skip_while(|line| *line != "[keys]")
782 .skip(1)
783 .take_while(|line| !line.starts_with('['))
784 .filter_map(|line| line.strip_prefix("# ").filter(|line| line.contains(" = ")))
785 .collect::<Vec<_>>()
786 .join("\n")
787 ))
788 .expect("documented default keybindings are valid");
789 assert_eq!(configured.keys, Config::default().keys);
790 }
791
792 #[test]
793 fn invalid_keybinding_is_rejected() {
794 let err = toml::from_str::<Config>(
795 r#"
796 [keys]
797 quit = ["ctrl+definitely-not-a-key"]
798 "#,
799 )
800 .expect_err("unknown keys must not be silently ignored");
801 assert!(err.to_string().contains("unknown key"));
802 }
803
804 #[test]
805 fn keybindings_accept_strings_arrays_and_empty_arrays() {
806 let configured: Config = toml::from_str(
807 r#"
808 [keys]
809 quit = "ctrl+q"
810 move_down = ["j", "down"]
811 open_search = []
812 "#,
813 )
814 .expect("valid config");
815
816 assert_eq!(configured.keys.quit.to_string(), "Ctrl + q");
817 assert_eq!(configured.keys.move_down.to_string(), "j/<Down>");
818 assert_eq!(configured.keys.open_search.to_string(), "<unmapped>");
819 assert_eq!(configured.keys.open_search.primary(), "<unmapped>");
820 }
821
822 #[cfg(feature = "tui-crossplatform")]
823 #[test]
824 fn keybindings_distinguish_modifiers_and_normalize_shifted_characters() {
825 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
826
827 let config: Config = toml::from_str(
828 r#"
829 [keys]
830 toggle_help = ["x"]
831 quit_immediately = ["ctrl+x"]
832 sort_by_name = ["shift+n"]
833 "#,
834 )
835 .expect("valid config");
836 let plain = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
837 let control = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
838
839 assert!(config.keys.toggle_help.matches(plain));
840 assert!(!config.keys.toggle_help.matches(control));
841 assert!(config.keys.quit_immediately.matches(control));
842 assert!(
843 config
844 .keys
845 .suspend
846 .matches(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL,))
847 );
848 assert!(
849 config
850 .keys
851 .sort_by_name
852 .matches(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT))
853 );
854 assert!(
855 config
856 .keys
857 .sort_by_name
858 .matches(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::SHIFT))
859 );
860 }
861
862 #[cfg(feature = "tui-crossplatform")]
863 #[test]
864 fn unmodified_punctuation_accepts_an_implicit_shift_modifier() {
865 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
866
867 let keys = Config::default().keys;
868 assert!(
869 keys.toggle_help
870 .matches(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT))
871 );
872 assert!(!keys.toggle_help.matches(KeyEvent::new(
873 KeyCode::Char('?'),
874 KeyModifiers::SHIFT | KeyModifiers::CONTROL,
875 )));
876 }
877
878 #[test]
879 fn notifications_default_to_enabled_and_can_be_disabled() {
880 let defaults: Config = toml::from_str("").expect("valid config");
881 assert!(defaults.notifications.scan_finished);
882 assert!(defaults.notifications.delete_finished);
883
884 let configured: Config = toml::from_str(
885 r"
886 [notifications]
887 scan_finished = false
888 delete_finished = false
889 ",
890 )
891 .expect("valid config");
892 assert!(!configured.notifications.scan_finished);
893 assert!(!configured.notifications.delete_finished);
894 }
895
896 #[test]
897 fn notifications_are_enabled_if_any_notification_is_enabled() {
898 let disabled: Config = toml::from_str(
899 r"
900 [notifications]
901 scan_finished = false
902 delete_finished = false
903 ",
904 )
905 .expect("valid config");
906 assert!(!disabled.notifications.any_enabled());
907
908 let partly_enabled: Config = toml::from_str(
909 r"
910 [notifications]
911 scan_finished = false
912 ",
913 )
914 .expect("valid config");
915 assert!(partly_enabled.notifications.any_enabled());
916 }
917
918 #[test]
919 fn parses_configured_byte_format() {
920 let config: Config = toml::from_str(
921 r#"
922 format = "mb"
923
924 [keys]
925 esc_navigates_back = false
926 "#,
927 )
928 .expect("valid config");
929
930 assert_eq!(config.format, Some(crate::ByteFormat::MB));
931 assert!(!config.keys.esc_navigates_back);
932 }
933
934 #[test]
935 fn parses_configured_gitignore() {
936 let config: Config = toml::from_str(
937 r#"
938 format = "mb"
939 gitignore = false
940
941 [keys]
942 esc_navigates_back = false
943 "#,
944 )
945 .expect("valid config");
946
947 assert_eq!(config.gitignore, Some(false));
948 }
949
950 #[test]
951 fn gitignore_defaults_to_enabled() {
952 let config: Config = toml::from_str(
953 r#"
954 format = "mb"
955
956 [keys]
957 esc_navigates_back = false
958 "#,
959 )
960 .expect("valid config");
961
962 assert_eq!(config.gitignore, None);
963 }
964
965 #[test]
966 fn parses_configured_cleanup_heuristics() {
967 let config: Config = toml::from_str(
968 r#"
969 format = "mb"
970 cleanup_heuristics = false
971
972 [keys]
973 esc_navigates_back = false
974 "#,
975 )
976 .expect("valid config");
977
978 assert_eq!(config.cleanup_heuristics, Some(false));
979 }
980
981 #[test]
982 fn cleanup_heuristics_defaults_to_enabled() {
983 let config: Config = toml::from_str(
984 r#"
985 format = "mb"
986
987 [keys]
988 esc_navigates_back = false
989 "#,
990 )
991 .expect("valid config");
992
993 assert_eq!(config.cleanup_heuristics, None);
994 }
995}