Skip to main content

dua/
config.rs

1use anyhow::{Context, Result, anyhow};
2
3use serde::{Deserialize, Deserializer, de};
4
5use std::{fmt, path::PathBuf, str::FromStr};
6
7/// Runtime configuration used by interactive and CLI components.
8///
9/// The configuration file is optional. If it cannot be found, defaults are used.
10/// See [`Config::load`] for details on fallback and error behavior.
11///
12/// Expected TOML structure:
13///
14/// ```toml
15/// format = "binary"
16///
17/// # Controls whether Git-ignored entry detection is enabled in interactive mode.
18/// # Supported values: true, false.
19/// # If unset, behavior defaults to true.
20/// # gitignore = true
21///
22/// # Controls whether cleanup heuristics are enabled in interactive mode.
23/// # Supported values: true, false.
24/// # If unset, behavior defaults to true.
25/// # cleanup_heuristics = true
26///
27/// [keys]
28/// esc_navigates_back = true
29/// sort_by_name = "ctrl+n"
30///
31/// [notifications]
32/// scan_finished = true
33/// delete_finished = true
34/// ```
35#[derive(Debug, Default, Deserialize)]
36#[serde(default)]
37pub struct Config {
38    /// Byte count format to use when `--format` and `DUA_FORMAT` are not set.
39    pub format: Option<crate::ByteFormat>,
40
41    /// Keybinding-related settings.
42    pub keys: KeysConfig,
43
44    /// Interactive completion-notification settings.
45    pub notifications: NotificationsConfig,
46
47    /// Whether Git-ignored entry detection is enabled.
48    ///
49    /// Supported values: `true` and `false`.
50    /// If unset, defaults to `true`.
51    pub gitignore: Option<bool>,
52
53    /// Whether cleanup heuristics are enabled.
54    ///
55    /// Supported values: `true` and `false`.
56    /// If unset, defaults to `true`.
57    pub cleanup_heuristics: Option<bool>,
58}
59
60/// Completion notifications emitted by interactive mode.
61#[derive(Debug, Deserialize)]
62#[serde(default)]
63pub struct NotificationsConfig {
64    /// Notify after initial scans and refreshes finish.
65    pub scan_finished: bool,
66    /// Notify after deletion or trash operations finish.
67    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    /// Whether any notification needs terminal focus tracking.
81    pub fn any_enabled(&self) -> bool {
82        self.scan_finished || self.delete_finished
83    }
84}
85
86/// One or more keys that invoke an interactive action.
87#[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    /// Return whether `key` invokes this action.
103    #[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    /// Render the first configured key for compact interface hints.
110    #[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    /// Return whether this action has no configured keys.
118    #[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/// One key and its optional modifiers.
163#[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    /// Convert this binding into the terminal event it describes.
384    #[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/// Keyboard interaction settings.
416#[derive(Debug, Deserialize, PartialEq, Eq)]
417#[serde(default)]
418pub struct KeysConfig {
419    /// Changes the configured close-pane key behavior in the interactive UI.
420    ///
421    /// If `true`, pressing it in the main pane ascends to the parent directory.
422    /// If `false`, it follows the quit behavior.
423    ///
424    /// Default: `true`.
425    #[serde(default = "default_esc_navigates_back")]
426    pub esc_navigates_back: bool,
427
428    /// Close the focused pane.
429    pub close_pane: KeyBindings,
430    /// Close a pane or quit from the main pane.
431    pub quit: KeyBindings,
432    /// Quit immediately without confirmation.
433    pub quit_immediately: KeyBindings,
434    /// Suspend the process and return control to the shell on Unix.
435    pub suspend: KeyBindings,
436    /// Move focus to the next open pane.
437    pub cycle_panes: KeyBindings,
438    /// Show or hide help.
439    pub toggle_help: KeyBindings,
440    /// Open the glob-search pane.
441    pub open_search: KeyBindings,
442    /// Move down one item.
443    pub move_down: KeyBindings,
444    /// Move up one item.
445    pub move_up: KeyBindings,
446    /// Move down one page.
447    pub page_down: KeyBindings,
448    /// Move up one page.
449    pub page_up: KeyBindings,
450    /// Move to the first item.
451    pub move_to_top: KeyBindings,
452    /// Move to the last item.
453    pub move_to_bottom: KeyBindings,
454    /// Enter the selected directory.
455    pub descend: KeyBindings,
456    /// Ascend to the parent directory.
457    pub ascend: KeyBindings,
458    /// Scan the directory above the current traversal root.
459    pub scan_parent: KeyBindings,
460    /// Sort by size.
461    pub sort_by_size: KeyBindings,
462    /// Sort by modification time.
463    pub sort_by_mtime: KeyBindings,
464    /// Cycle the modification-time mode or column.
465    pub cycle_mtime_mode: KeyBindings,
466    /// Sort by item count.
467    pub sort_by_count: KeyBindings,
468    /// Show or hide the item-count column.
469    pub toggle_count_column: KeyBindings,
470    /// Sort by name.
471    pub sort_by_name: KeyBindings,
472    /// Cycle byte visualizations.
473    pub cycle_visualization: KeyBindings,
474    /// Open the selected entry externally.
475    pub open_entry: KeyBindings,
476    /// Toggle the selected entry's mark.
477    pub toggle_mark: KeyBindings,
478    /// Mark the selected entry for deletion.
479    pub mark_for_deletion: KeyBindings,
480    /// Toggle the selected entry's mark and move down.
481    pub toggle_mark_and_move_down: KeyBindings,
482    /// Toggle all visible entry marks.
483    pub toggle_all: KeyBindings,
484    /// Toggle cleanup-candidate detection.
485    pub toggle_cleanup: KeyBindings,
486    /// Mark cleanup candidates.
487    pub mark_cleanup: KeyBindings,
488    /// Toggle Git-ignore detection.
489    pub toggle_gitignore: KeyBindings,
490    /// Mark Git-ignored entries.
491    pub mark_gitignore: KeyBindings,
492    /// Refresh the selected entry.
493    pub refresh_selected: KeyBindings,
494    /// Refresh the current view.
495    pub refresh_all: KeyBindings,
496    /// Remove the selected mark.
497    pub remove_mark: KeyBindings,
498    /// Remove all marks.
499    pub remove_all_marks: KeyBindings,
500    /// Permanently delete marked entries.
501    pub delete_marked: KeyBindings,
502    /// Move marked entries to the trash.
503    pub trash_marked: KeyBindings,
504    /// Submit the glob search.
505    pub search_confirm: KeyBindings,
506    /// Toggle glob-search case sensitivity.
507    pub search_toggle_case: KeyBindings,
508    /// Delete the preceding glob-search character.
509    pub search_backspace: KeyBindings,
510    /// Move the glob-search cursor left.
511    pub search_left: KeyBindings,
512    /// Move the glob-search cursor right.
513    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    /// Load configuration from disk.
573    ///
574    /// Behavior:
575    /// - If no platform configuration directory is available, returns defaults.
576    /// - If the config file does not exist, returns defaults.
577    /// - If the config file exists but cannot be read, returns an error with path context.
578    /// - If TOML parsing fails, returns an error with path context.
579    ///
580    /// Unknown keys are ignored. Missing supported keys fall back to defaults.
581    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    /// Default TOML content used when initializing a new configuration file.
607    #[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    /// Return the expected configuration file location for the current platform.
699    ///
700    /// The path is:
701    /// - Linux/Unix: `$XDG_CONFIG_HOME/dua-cli/config.toml` (or equivalent fallback)
702    /// - Windows: `%APPDATA%\\dua-cli\\config.toml`
703    /// - macOS: `~/Library/Application Support/dua-cli/config.toml`
704    ///
705    /// Returns an error if the platform config directory cannot be determined.
706    pub fn path() -> Result<PathBuf> {
707        // Use the OS-specific configuration directory (e.g. $XDG_CONFIG_HOME, %APPDATA%, or
708        // ~/Library/Application Support) as provided by the `dirs` crate.
709        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}