Skip to main content

trail/config/
schema.rs

1//! Serde structs mirroring the TOML config shape.
2//!
3//! Covers `[general]`, `[navigation]`, `[preview]`, `[terminal]`, `[theme]`,
4//! `[keymap]`, and `[plugins]` sections. All structs reject unknown TOML keys so user typos are
5//! surfaced instead of silently ignored.
6
7use std::collections::HashMap;
8
9use serde::Deserialize;
10use thiserror::Error;
11
12use super::terminal::{
13    parse_confirm, parse_terminal_chord, TERMINAL_HEIGHT_RANGE, TERMINAL_HEIGHT_REASON,
14};
15pub use super::terminal::{TerminalConfig, TerminalProfile};
16
17/// Errors produced while applying a runtime `:set` update.
18#[derive(Debug, Error, Clone, PartialEq, Eq)]
19pub enum SetConfigError {
20    /// The provided config key is not known to the schema.
21    #[error("unknown config key '{0}'")]
22    UnknownKey(String),
23    /// A value could not be parsed as the type required by the key.
24    #[error("{key}: invalid value '{value}' ({reason})")]
25    InvalidValue {
26        /// The key being updated.
27        key: String,
28        /// The raw value entered by the user.
29        value: String,
30        /// Human-readable parse or validation reason.
31        reason: String,
32    },
33}
34
35/// Explanation attached to a rejected `[general] shell` value.
36///
37/// Shared by `validate` and `set_value` so a bad spec reads the same whether it
38/// came from the config file at startup or from `:set` at runtime.
39const SHELL_SPEC_REASON: &str =
40    "must be a program optionally followed by flags, e.g. \"pwsh -NoProfile -Command\"; \
41     quote a path containing spaces";
42
43/// Explanation attached to a rejected `[general] shell_pause` value.
44///
45/// Shared by `validate` and `set_value`, for the same reason as
46/// [`SHELL_SPEC_REASON`].
47const SHELL_PAUSE_REASON: &str = "must be always, on_error or never";
48
49/// Explanation attached to a rejected `[general] delete_mode` value.
50const DELETE_MODE_REASON: &str = "must be trash or permanent";
51
52/// Explanation attached to a rejected `[general] terminal_title` value.
53const TERMINAL_TITLE_REASON: &str = "must be off, dir or path";
54
55/// Top-level Trail configuration.
56#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
57#[serde(deny_unknown_fields)]
58pub struct TrailConfig {
59    /// General behavior settings.
60    pub general: GeneralConfig,
61    /// Navigation panel settings: listing order and per-entry details.
62    pub navigation: NavigationConfig,
63    /// Preview pane settings, including inline image rendering.
64    pub preview: PreviewConfig,
65    /// Terminal panel settings: shell profiles, size and confirmations.
66    pub terminal: TerminalConfig,
67    /// UI color settings.
68    pub theme: ThemeConfig,
69    /// Key binding overrides.
70    pub keymap: KeymapConfig,
71    /// Plugin loader settings.
72    pub plugins: PluginsConfig,
73}
74
75impl TrailConfig {
76    /// Validates semantic constraints that serde cannot express for map keys.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`SetConfigError`] if a keymap action name or binding string is
81    /// not recognised, or if a color value is invalid.
82    pub fn validate(&self) -> Result<(), SetConfigError> {
83        if self.general.editor.trim().is_empty() {
84            return Err(invalid_value("general.editor", "", "must not be empty"));
85        }
86        // Blank is the documented "use the platform default" sentinel, so only
87        // a spec the user actually wrote is held to being spawnable.
88        if !self.general.shell.trim().is_empty()
89            && crate::actions::shell_exec::split_shell_spec(&self.general.shell).is_none()
90        {
91            return Err(invalid_value(
92                "general.shell",
93                &self.general.shell,
94                SHELL_SPEC_REASON,
95            ));
96        }
97        if crate::actions::shell_exec::ShellPause::parse(&self.general.shell_pause).is_none() {
98            return Err(invalid_value(
99                "general.shell_pause",
100                &self.general.shell_pause,
101                SHELL_PAUSE_REASON,
102            ));
103        }
104        if crate::actions::fs_ops::DeleteMode::parse(&self.general.delete_mode).is_none() {
105            return Err(invalid_value(
106                "general.delete_mode",
107                &self.general.delete_mode,
108                DELETE_MODE_REASON,
109            ));
110        }
111        if crate::window_title::TitleMode::parse(&self.general.terminal_title).is_none() {
112            return Err(invalid_value(
113                "general.terminal_title",
114                &self.general.terminal_title,
115                TERMINAL_TITLE_REASON,
116            ));
117        }
118        if self.general.text_sync_threshold_kb == 0 {
119            return Err(invalid_value(
120                "general.text_sync_threshold_kb",
121                "0",
122                "must be greater than zero",
123            ));
124        }
125        if crate::app::sort::SortBy::parse(&self.navigation.sort_by).is_none() {
126            return Err(invalid_value(
127                "navigation.sort_by",
128                &self.navigation.sort_by,
129                crate::app::sort::SORT_BY_REASON,
130            ));
131        }
132        if crate::ui::nav_panel::EntryDetails::parse(&self.navigation.entry_details).is_none() {
133            return Err(invalid_value(
134                "navigation.entry_details",
135                &self.navigation.entry_details,
136                crate::ui::nav_panel::ENTRY_DETAILS_REASON,
137            ));
138        }
139        // `navigation.scroll_margin` needs no range check: zero is "no margin",
140        // and a margin larger than half the pane is capped at render time, where
141        // the pane height is known, rather than rejected here, where it is not.
142        if !self.preview.image_protocol.eq_ignore_ascii_case("auto")
143            && crate::preview::graphics::ImageProtocol::parse(&self.preview.image_protocol)
144                .is_none()
145        {
146            return Err(invalid_value(
147                "preview.image_protocol",
148                &self.preview.image_protocol,
149                "must be auto, kitty, iterm2, sixel, halfblocks or none",
150            ));
151        }
152        // The cell size needs no range check: zero means "detect it", and any
153        // other u16 is a legitimate, if unusual, cell dimension.
154        if self.preview.max_lines == 0 || self.preview.max_lines > PREVIEW_MAX_LINES_LIMIT {
155            return Err(invalid_value(
156                "preview.max_lines",
157                &self.preview.max_lines.to_string(),
158                "must be between 1 and 100000",
159            ));
160        }
161        self.terminal.validate()?;
162        validate_color_value("theme.foreground", &self.theme.foreground)?;
163        validate_color_value("theme.background", &self.theme.background)?;
164        validate_color_value("theme.border", &self.theme.border)?;
165        validate_color_value("theme.selection_fg", &self.theme.selection_fg)?;
166        validate_color_value("theme.selection_bg", &self.theme.selection_bg)?;
167        validate_color_value("theme.directory", &self.theme.directory)?;
168        validate_color_value("theme.symlink", &self.theme.symlink)?;
169        validate_color_value("theme.hidden", &self.theme.hidden)?;
170        validate_color_value("theme.status_fg", &self.theme.status_fg)?;
171        validate_color_value("theme.error", &self.theme.error)?;
172        validate_color_value("theme.search", &self.theme.search)?;
173        validate_color_value("theme.command", &self.theme.command)?;
174        validate_color_value("theme.git_clean", &self.theme.git_clean)?;
175        validate_color_value("theme.git_dirty", &self.theme.git_dirty)?;
176        validate_keymap_table("keymap.navigation", &self.keymap.navigation, NAV_ACTIONS)?;
177        validate_keymap_table("keymap.search", &self.keymap.search, SEARCH_ACTIONS)?;
178        for (action, binding) in &self.keymap.search {
179            validate_search_binding(&format!("keymap.search.{action}"), binding)?;
180        }
181        for (action, binding) in &self.keymap.terminal {
182            let key = format!("keymap.terminal.{action}");
183            if !crate::terminal::TERMINAL_ACTIONS.contains(&action.as_str()) {
184                return Err(SetConfigError::UnknownKey(key));
185            }
186            parse_terminal_chord(&key, binding)?;
187        }
188        Ok(())
189    }
190
191    /// Applies a typed runtime setting update.
192    ///
193    /// Supports section-qualified keys such as `general.git_status_enabled`
194    /// and common short aliases such as `git_status_enabled`.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`SetConfigError`] when `key` is unknown or `value` does not
199    /// parse for the selected setting.
200    pub fn set_value(&mut self, key: &str, value: &str) -> Result<(), SetConfigError> {
201        match key {
202            "general.editor" | "editor" => {
203                let editor = value.trim();
204                if editor.is_empty() {
205                    return Err(invalid_value(key, value, "must not be empty"));
206                }
207                self.general.editor = editor.to_owned();
208            }
209            "general.shell" | "shell" => {
210                // An explicit empty value cannot arrive here — the command
211                // parser rejects `:set shell` with no value — so any spec that
212                // reaches this point is one the user meant, and must be usable.
213                if crate::actions::shell_exec::split_shell_spec(value).is_none() {
214                    return Err(invalid_value(key, value, SHELL_SPEC_REASON));
215                }
216                self.general.shell = value.trim().to_owned();
217            }
218            "general.shell_pause" | "shell_pause" => {
219                if crate::actions::shell_exec::ShellPause::parse(value).is_none() {
220                    return Err(invalid_value(key, value, SHELL_PAUSE_REASON));
221                }
222                self.general.shell_pause = value.trim().to_ascii_lowercase();
223            }
224            "general.delete_mode" | "delete_mode" => {
225                if crate::actions::fs_ops::DeleteMode::parse(value).is_none() {
226                    return Err(invalid_value(key, value, DELETE_MODE_REASON));
227                }
228                self.general.delete_mode = value.trim().to_ascii_lowercase();
229            }
230            "general.text_sync_threshold_kb" | "text_sync_threshold_kb" => {
231                self.general.text_sync_threshold_kb = parse_positive_usize(key, value)?;
232            }
233            "general.show_version" | "show_version" => {
234                self.general.show_version = parse_bool(key, value)?;
235            }
236            "general.terminal_title" | "terminal_title" => {
237                if crate::window_title::TitleMode::parse(value).is_none() {
238                    return Err(invalid_value(key, value, TERMINAL_TITLE_REASON));
239                }
240                self.general.terminal_title = value.trim().to_ascii_lowercase();
241            }
242            "general.git_status_enabled" | "git_status_enabled" => {
243                self.general.git_status_enabled = parse_bool(key, value)?;
244            }
245            "general.fs_watch_debounce_ms" | "fs_watch_debounce_ms" => {
246                self.general.fs_watch_debounce_ms = parse_u64(key, value)?;
247            }
248            "navigation.sort_by" | "sort_by" => {
249                if crate::app::sort::SortBy::parse(value).is_none() {
250                    return Err(invalid_value(key, value, crate::app::sort::SORT_BY_REASON));
251                }
252                self.navigation.sort_by = value.trim().to_ascii_lowercase();
253            }
254            "navigation.sort_reverse" | "sort_reverse" => {
255                self.navigation.sort_reverse = parse_bool(key, value)?;
256            }
257            "navigation.dirs_first" | "dirs_first" => {
258                self.navigation.dirs_first = parse_bool(key, value)?;
259            }
260            "navigation.entry_details" | "entry_details" => {
261                if crate::ui::nav_panel::EntryDetails::parse(value).is_none() {
262                    return Err(invalid_value(
263                        key,
264                        value,
265                        crate::ui::nav_panel::ENTRY_DETAILS_REASON,
266                    ));
267                }
268                self.navigation.entry_details = value.trim().to_ascii_lowercase();
269            }
270            "navigation.scroll_margin" | "scroll_margin" => {
271                self.navigation.scroll_margin = parse_usize(key, value)?;
272            }
273            "preview.image_protocol" | "image_protocol" => {
274                if !value.trim().eq_ignore_ascii_case("auto")
275                    && crate::preview::graphics::ImageProtocol::parse(value).is_none()
276                {
277                    return Err(invalid_value(
278                        key,
279                        value,
280                        "must be auto, kitty, iterm2, sixel, halfblocks or none",
281                    ));
282                }
283                self.preview.image_protocol = value.trim().to_ascii_lowercase();
284            }
285            "preview.image_cell_width" | "image_cell_width" => {
286                self.preview.image_cell_width = parse_cell_size(key, value)?;
287            }
288            "preview.image_cell_height" | "image_cell_height" => {
289                self.preview.image_cell_height = parse_cell_size(key, value)?;
290            }
291            "preview.max_lines" | "max_lines" => {
292                let max_lines = parse_positive_usize(key, value)?;
293                if max_lines > PREVIEW_MAX_LINES_LIMIT {
294                    return Err(invalid_value(key, value, "must be at most 100000"));
295                }
296                self.preview.max_lines = max_lines;
297            }
298            "terminal.height" => {
299                let height = value
300                    .trim()
301                    .parse::<u16>()
302                    .map_err(|_| invalid_value(key, value, TERMINAL_HEIGHT_REASON))?;
303                if !TERMINAL_HEIGHT_RANGE.contains(&height) {
304                    return Err(invalid_value(key, value, TERMINAL_HEIGHT_REASON));
305                }
306                self.terminal.height = height;
307            }
308            "terminal.confirm_quit" | "confirm_quit" => {
309                self.terminal.confirm_quit = parse_confirm(key, value)?;
310            }
311            "terminal.confirm_close" | "confirm_close" => {
312                self.terminal.confirm_close = parse_confirm(key, value)?;
313            }
314            "terminal.default_profile" | "default_profile" => {
315                let name = value.trim();
316                if !self.terminal.has_profile(name) {
317                    return Err(invalid_value(key, value, "names no [[terminal.profile]]"));
318                }
319                self.terminal.default_profile = name.to_owned();
320            }
321            "theme.foreground" => self.theme.foreground = parse_color_value(key, value)?,
322            "theme.background" => self.theme.background = parse_color_value(key, value)?,
323            "theme.border" => self.theme.border = parse_color_value(key, value)?,
324            "theme.selection_fg" => self.theme.selection_fg = parse_color_value(key, value)?,
325            "theme.selection_bg" => self.theme.selection_bg = parse_color_value(key, value)?,
326            "theme.directory" => self.theme.directory = parse_color_value(key, value)?,
327            "theme.symlink" => self.theme.symlink = parse_color_value(key, value)?,
328            "theme.hidden" => self.theme.hidden = parse_color_value(key, value)?,
329            "theme.status_fg" => self.theme.status_fg = parse_color_value(key, value)?,
330            "theme.error" => self.theme.error = parse_color_value(key, value)?,
331            "theme.search" => self.theme.search = parse_color_value(key, value)?,
332            "theme.command" => self.theme.command = parse_color_value(key, value)?,
333            "theme.git_clean" => self.theme.git_clean = parse_color_value(key, value)?,
334            "theme.git_dirty" => self.theme.git_dirty = parse_color_value(key, value)?,
335            key if key.starts_with("keymap.navigation.") => {
336                let action = key.trim_start_matches("keymap.navigation.");
337                if !NAV_ACTIONS.contains(&action) {
338                    return Err(SetConfigError::UnknownKey(key.to_owned()));
339                }
340                self.keymap
341                    .navigation
342                    .insert(action.to_owned(), parse_key_binding(key, value)?);
343            }
344            key if key.starts_with("keymap.search.") => {
345                let action = key.trim_start_matches("keymap.search.");
346                if !SEARCH_ACTIONS.contains(&action) {
347                    return Err(SetConfigError::UnknownKey(key.to_owned()));
348                }
349                let binding = parse_key_binding(key, value)?;
350                validate_search_binding(key, &binding)?;
351                self.keymap.search.insert(action.to_owned(), binding);
352            }
353            key if key.starts_with("keymap.terminal.") => {
354                let action = key.trim_start_matches("keymap.terminal.");
355                if !crate::terminal::TERMINAL_ACTIONS.contains(&action) {
356                    return Err(SetConfigError::UnknownKey(key.to_owned()));
357                }
358                parse_terminal_chord(key, value)?;
359                self.keymap
360                    .terminal
361                    .insert(action.to_owned(), value.trim().to_owned());
362            }
363            _ => return Err(SetConfigError::UnknownKey(key.to_owned())),
364        }
365
366        Ok(())
367    }
368}
369
370/// General behavior settings.
371#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
372#[serde(deny_unknown_fields)]
373pub struct GeneralConfig {
374    /// Editor command used when opening a file.
375    pub editor: String,
376    /// Shell that runs `!<command>` and `:git`, as a program plus the flags
377    /// that make it read a command string — e.g. `pwsh -NoProfile -Command`.
378    ///
379    /// Blank means "use the platform default", which is
380    /// [`crate::actions::shell_exec::DEFAULT_SHELL_ARGV`]. Parsed by
381    /// [`crate::actions::shell_exec::split_shell_spec`]; this is not itself a
382    /// shell command, so it gets no expansion or pipelines.
383    pub shell: String,
384    /// When to wait for Enter after a `!<command>` or `:git` finishes, before
385    /// Trail takes the screen back: `always`, `on_error` or `never`.
386    ///
387    /// Parsed by [`crate::actions::shell_exec::ShellPause::parse`].
388    pub shell_pause: String,
389    /// How much of a file, in KiB, a preview reads before stopping short.
390    ///
391    /// Named for what it used to decide — which files were highlighted on the UI
392    /// thread, which none are now. Renaming it would break every config that sets
393    /// it for no behavioural gain, so it keeps the name and has a documented
394    /// meaning instead.
395    pub text_sync_threshold_kb: usize,
396    /// Where `dd` sends the selected entry: `trash` or `permanent`.
397    ///
398    /// Parsed by [`crate::actions::fs_ops::DeleteMode::parse`].
399    pub delete_mode: String,
400    /// Whether the preview panel's top border carries Trail's version.
401    ///
402    /// A diagnostic rather than chrome: it answers "which build is this
403    /// window?" without leaving the session, which `trail --version` cannot do
404    /// for a process that is already running. Off by default.
405    pub show_version: bool,
406    /// What the terminal's window or tab title says while Trail runs: `off`,
407    /// `dir` (`trail <directory name>`) or `path` (`trail <full path>`).
408    ///
409    /// Parsed by [`crate::window_title::TitleMode::parse`]; read every frame,
410    /// so `:set` takes effect at once.
411    pub terminal_title: String,
412    /// Whether git status workers should run.
413    pub git_status_enabled: bool,
414    /// Filesystem watcher debounce window in milliseconds.
415    pub fs_watch_debounce_ms: u64,
416}
417
418/// Navigation panel settings: how the listing is ordered and what it shows.
419#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
420#[serde(deny_unknown_fields)]
421pub struct NavigationConfig {
422    /// Which property the listing is ordered by: `name`, `size`, `modified` or
423    /// `extension`.
424    ///
425    /// Parsed by [`crate::app::sort::SortBy::parse`]. This seeds the first
426    /// tab's order only — each tab owns its own from then on, and `:sort`
427    /// changes the active tab's without touching the others.
428    pub sort_by: String,
429    /// Whether the order within each group is flipped.
430    pub sort_reverse: bool,
431    /// Whether directories are grouped ahead of files whatever `sort_by` says.
432    pub dirs_first: bool,
433    /// What the listing shows beside each name: `none`, `size`, `modified` or
434    /// `both`.
435    ///
436    /// Parsed by [`crate::ui::nav_panel::EntryDetails::parse`]. Unlike the sort
437    /// keys this is not per tab — it describes the panel rather than a place
438    /// you are working.
439    pub entry_details: String,
440    /// Rows kept between the selection and the top or bottom of the pane
441    /// while scrolling, like vim's `scrolloff`.
442    ///
443    /// `0` lets the selection reach either edge. Any value is accepted: the
444    /// renderer caps it at half the pane, which keeps the selection centred.
445    pub scroll_margin: usize,
446}
447
448impl NavigationConfig {
449    /// The [`crate::app::sort::SortSettings`] this section describes.
450    ///
451    /// Falls back to the default order for an unparseable `sort_by`, which
452    /// [`TrailConfig::validate`] has already rejected — the fallback exists so
453    /// this cannot panic rather than because it is reachable.
454    pub fn sort_settings(&self) -> crate::app::sort::SortSettings {
455        crate::app::sort::SortSettings {
456            by: crate::app::sort::SortBy::parse(&self.sort_by).unwrap_or_default(),
457            reverse: self.sort_reverse,
458            dirs_first: self.dirs_first,
459        }
460    }
461
462    /// The [`crate::ui::nav_panel::EntryDetails`] this section describes.
463    ///
464    /// Falls back to showing nothing for an unparseable value, on the same
465    /// reasoning as [`NavigationConfig::sort_settings`].
466    pub fn details(&self) -> crate::ui::nav_panel::EntryDetails {
467        crate::ui::nav_panel::EntryDetails::parse(&self.entry_details).unwrap_or_default()
468    }
469}
470
471/// Preview pane settings.
472#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
473#[serde(deny_unknown_fields)]
474pub struct PreviewConfig {
475    /// Inline-image protocol to use: `"auto"` to detect from the environment,
476    /// or one of `kitty`, `iterm2`, `sixel`, `halfblocks`, `none`.
477    pub image_protocol: String,
478    /// Width, in pixels, of one terminal character cell, or `0` to detect it.
479    ///
480    /// Used to convert the preview pane's size in cells into the pixel size an
481    /// image is encoded at. Only some platforms report it, so `0` means
482    /// "measure it, and assume a conservative default if that fails" — see
483    /// `crate::preview::graphics`.
484    pub image_cell_width: u16,
485    /// Height, in pixels, of one terminal character cell, or `0` to detect it.
486    pub image_cell_height: u16,
487    /// Maximum number of lines a text preview loads.
488    ///
489    /// The preview scrolls within this window, and a longer file is marked as
490    /// truncated in the pane's footer. Raising it costs the highlight worker
491    /// proportionally more time and memory per preview; capped at
492    /// [`PREVIEW_MAX_LINES_LIMIT`].
493    pub max_lines: usize,
494}
495
496/// Upper bound accepted for `[preview] max_lines`.
497///
498/// A cap is needed because the value decides how much of a file one worker task
499/// reads and highlights: a mistyped value must not pin a worker on a multi-
500/// gigabyte file. 100 000 lines is far past any file a preview pane is useful
501/// for, so it constrains only typos.
502pub const PREVIEW_MAX_LINES_LIMIT: usize = 100_000;
503
504/// UI color configuration.
505#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
506#[serde(deny_unknown_fields)]
507pub struct ThemeConfig {
508    /// Default foreground color.
509    pub foreground: String,
510    /// Default background color.
511    pub background: String,
512    /// Panel border color.
513    pub border: String,
514    /// Selected row foreground color.
515    pub selection_fg: String,
516    /// Selected row background color.
517    pub selection_bg: String,
518    /// Directory entry color.
519    pub directory: String,
520    /// Symlink entry color.
521    pub symlink: String,
522    /// Hidden entry color.
523    pub hidden: String,
524    /// Status bar foreground color.
525    pub status_fg: String,
526    /// Error message color.
527    pub error: String,
528    /// Search mode accent color.
529    pub search: String,
530    /// Command mode accent color.
531    pub command: String,
532    /// Clean git indicator color.
533    pub git_clean: String,
534    /// Dirty git indicator color.
535    pub git_dirty: String,
536}
537
538/// Configurable key bindings.
539#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
540#[serde(deny_unknown_fields)]
541pub struct KeymapConfig {
542    /// Navigation-mode bindings by action name.
543    pub navigation: HashMap<String, String>,
544    /// Search-mode bindings by action name.
545    pub search: HashMap<String, String>,
546    /// Terminal panel bindings by action name. Unlike the other tables these
547    /// are single chords (`ctrl-.`, `f12`), never sequences.
548    pub terminal: HashMap<String, String>,
549}
550
551/// Plugin loader settings.
552#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
553#[serde(deny_unknown_fields)]
554pub struct PluginsConfig {
555    /// Plugin names enabled for loading in Phase 8.
556    pub enabled: Vec<String>,
557}
558
559/// Parses one axis of `[preview]`'s character cell size.
560///
561/// `0` is accepted, and means "detect it" — see
562/// [`crate::preview::graphics::AUTO_CELL_SIZE`].
563fn parse_cell_size(key: &str, value: &str) -> Result<u16, SetConfigError> {
564    value
565        .trim()
566        .parse()
567        .map_err(|_| invalid_value(key, value, "expected a whole number, or 0 to detect"))
568}
569
570fn parse_bool(key: &str, value: &str) -> Result<bool, SetConfigError> {
571    match value.trim().to_ascii_lowercase().as_str() {
572        "true" | "yes" | "on" | "1" => Ok(true),
573        "false" | "no" | "off" | "0" => Ok(false),
574        _ => Err(invalid_value(key, value, "expected true or false")),
575    }
576}
577
578fn parse_positive_usize(key: &str, value: &str) -> Result<usize, SetConfigError> {
579    let parsed = value
580        .trim()
581        .parse::<usize>()
582        .map_err(|_| invalid_value(key, value, "expected a positive integer"))?;
583    if parsed == 0 {
584        return Err(invalid_value(key, value, "must be greater than zero"));
585    }
586    Ok(parsed)
587}
588
589fn parse_usize(key: &str, value: &str) -> Result<usize, SetConfigError> {
590    value
591        .trim()
592        .parse::<usize>()
593        .map_err(|_| invalid_value(key, value, "expected a non-negative integer"))
594}
595
596fn parse_u64(key: &str, value: &str) -> Result<u64, SetConfigError> {
597    value
598        .trim()
599        .parse::<u64>()
600        .map_err(|_| invalid_value(key, value, "expected a non-negative integer"))
601}
602
603fn parse_color_value(key: &str, value: &str) -> Result<String, SetConfigError> {
604    let trimmed = value.trim();
605    validate_color_value(key, trimmed)?;
606    Ok(trimmed.to_owned())
607}
608
609fn parse_key_binding(key: &str, value: &str) -> Result<String, SetConfigError> {
610    let trimmed = value.trim();
611    validate_key_binding(key, trimmed)?;
612    Ok(trimmed.to_owned())
613}
614
615fn validate_keymap_table(
616    table: &str,
617    values: &HashMap<String, String>,
618    allowed_actions: &[&str],
619) -> Result<(), SetConfigError> {
620    for (action, binding) in values {
621        if !allowed_actions.contains(&action.as_str()) {
622            return Err(SetConfigError::UnknownKey(format!("{table}.{action}")));
623        }
624        validate_key_binding(&format!("{table}.{action}"), binding)?;
625    }
626    Ok(())
627}
628
629/// Rejects a Search Mode binding that would shadow typing.
630///
631/// Search Mode appends every unmodified character to the query, so binding a
632/// bare character to an action would make that character unsearchable. The
633/// shipped defaults used to bind `move_down = "j"` and `move_up = "k"`, which
634/// meant neither letter could be typed into a search at all.
635///
636/// Named and modified keys are unaffected, because they are not text: `up`,
637/// `down`, `enter`, `esc`, `backspace`, `ctrl-n`.
638fn validate_search_binding(key: &str, binding: &str) -> Result<(), SetConfigError> {
639    if binding.chars().count() == 1 {
640        return Err(invalid_value(
641            key,
642            binding,
643            "must not be a single character - those are typed into the query;              use a named or modified key such as up, down, enter, esc or ctrl-n",
644        ));
645    }
646    Ok(())
647}
648
649fn validate_key_binding(key: &str, value: &str) -> Result<(), SetConfigError> {
650    if value.is_empty() {
651        return Err(invalid_value(key, value, "must not be empty"));
652    }
653    if value.chars().count() == 1 {
654        return Ok(());
655    }
656    let lower = value.to_ascii_lowercase();
657    let known = [
658        "enter",
659        "esc",
660        "backspace",
661        "tab",
662        "left",
663        "right",
664        "up",
665        "down",
666        "home",
667        "end",
668    ];
669    if known.contains(&lower.as_str()) {
670        return Ok(());
671    }
672    if let Some(rest) = lower.strip_prefix("ctrl-") {
673        if rest.chars().count() == 1 {
674            return Ok(());
675        }
676    }
677    // `shift-<named key>` — shift is only meaningful on keys that are not text;
678    // a shifted character arrives as the capital itself (`G`, `J`).
679    if let Some(rest) = lower.strip_prefix("shift-") {
680        if known.contains(&rest) {
681            return Ok(());
682        }
683    }
684    if value.chars().all(|ch| !ch.is_control()) {
685        return Ok(());
686    }
687    Err(invalid_value(
688        key,
689        value,
690        "expected a printable key, named key, or ctrl-x chord",
691    ))
692}
693
694fn validate_color_value(key: &str, value: &str) -> Result<(), SetConfigError> {
695    let lower = value.trim().to_ascii_lowercase();
696    let named = [
697        "black",
698        "red",
699        "green",
700        "yellow",
701        "blue",
702        "magenta",
703        "cyan",
704        "gray",
705        "grey",
706        "dark_gray",
707        "dark_grey",
708        "darkgray",
709        "darkgrey",
710        "white",
711        "reset",
712    ];
713    if named.contains(&lower.as_str()) {
714        return Ok(());
715    }
716    if lower.len() == 7
717        && lower.starts_with('#')
718        && lower[1..].chars().all(|ch| ch.is_ascii_hexdigit())
719    {
720        return Ok(());
721    }
722    Err(invalid_value(
723        key,
724        value,
725        "expected a named color or #rrggbb",
726    ))
727}
728
729pub(super) fn invalid_value(key: &str, value: &str, reason: &str) -> SetConfigError {
730    SetConfigError::InvalidValue {
731        key: key.to_owned(),
732        value: value.to_owned(),
733        reason: reason.to_owned(),
734    }
735}
736
737const NAV_ACTIONS: &[&str] = &[
738    "move_down",
739    "move_up",
740    "jump_top",
741    "jump_bottom",
742    "enter_or_open",
743    "go_parent",
744    "history_back",
745    "history_forward",
746    "return_from_jump",
747    "refresh",
748    "toggle_hidden",
749    "sort_name",
750    "sort_size",
751    "sort_time",
752    "sort_extension",
753    "sort_reverse",
754    "sort_dirs_first",
755    "toggle_details",
756    "copy_absolute_path",
757    "copy_relative_path",
758    "copy_filename",
759    "copy_content",
760    "delete",
761    "toggle_mark",
762    "clear_marks",
763    "paste_copy",
764    "paste_move",
765    "enter_visual",
766    "repeat_command",
767    "enter_search",
768    "enter_command",
769    "quit",
770    "open_with_os",
771    "new_tab",
772    "close_tab",
773    "switch_tab_next",
774    "switch_tab_prev",
775    "preview_scroll_down",
776    "preview_scroll_up",
777    "preview_page_down",
778    "preview_page_up",
779    "preview_scroll_top",
780    "preview_scroll_bottom",
781];
782
783const SEARCH_ACTIONS: &[&str] = &["exit", "confirm", "move_down", "move_up", "delete_char"];
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn search_bindings_reject_bare_characters() {
791        let mut config = crate::config::load(None).unwrap();
792        config
793            .keymap
794            .search
795            .insert("move_down".to_owned(), "j".to_owned());
796
797        let err = config.validate().unwrap_err();
798        assert!(
799            format!("{err}").contains("single character"),
800            "expected the shadowing explanation, got: {err}"
801        );
802    }
803
804    #[test]
805    fn set_value_rejects_a_bare_character_search_binding() {
806        let mut config = crate::config::load(None).unwrap();
807        let err = config.set_value("keymap.search.move_up", "k").unwrap_err();
808        assert!(format!("{err}").contains("single character"));
809        // The rejected binding must not have been applied.
810        assert_eq!(config.keymap.search.get("move_up"), Some(&"up".to_owned()));
811    }
812
813    #[test]
814    fn set_value_accepts_and_bounds_preview_max_lines() {
815        let mut config = crate::config::load(None).unwrap();
816        config.set_value("preview.max_lines", "8000").unwrap();
817        assert_eq!(config.preview.max_lines, 8000);
818        // The short alias reaches the same field.
819        config.set_value("max_lines", "500").unwrap();
820        assert_eq!(config.preview.max_lines, 500);
821
822        for rejected in ["0", "200000"] {
823            assert!(
824                config.set_value("preview.max_lines", rejected).is_err(),
825                "{rejected} must be rejected"
826            );
827        }
828        // A rejected value must not have been applied.
829        assert_eq!(config.preview.max_lines, 500);
830    }
831
832    #[test]
833    fn preview_scroll_bindings_are_configurable() {
834        let mut config = crate::config::load(None).unwrap();
835        config
836            .set_value("keymap.navigation.preview_scroll_down", "J")
837            .unwrap();
838        config
839            .set_value("keymap.navigation.preview_scroll_bottom", "shift-end")
840            .unwrap();
841        assert_eq!(
842            config.keymap.navigation.get("preview_scroll_bottom"),
843            Some(&"shift-end".to_owned())
844        );
845        config.validate().expect("shipped defaults must validate");
846    }
847
848    #[test]
849    fn search_bindings_accept_named_and_modified_keys() {
850        let mut config = crate::config::load(None).unwrap();
851        for binding in ["down", "ctrl-n", "enter", "esc"] {
852            config
853                .set_value("keymap.search.move_down", binding)
854                .unwrap_or_else(|e| panic!("{binding} should be allowed: {e}"));
855        }
856    }
857
858    #[test]
859    fn set_value_updates_general_and_keymap_values() {
860        let mut config = crate::config::load(None).unwrap();
861        config
862            .set_value("git_status_enabled", "false")
863            .expect("bool setting should parse");
864        config
865            .set_value("keymap.navigation.move_down", "n")
866            .expect("keymap setting should parse");
867
868        assert!(!config.general.git_status_enabled);
869        assert_eq!(
870            config.keymap.navigation.get("move_down"),
871            Some(&"n".to_owned())
872        );
873    }
874
875    #[test]
876    fn the_shipped_shell_default_is_blank_and_means_platform_default() {
877        let config = crate::config::load(None).unwrap();
878        assert_eq!(config.general.shell, "");
879        // Blank must survive validation — it is the sentinel, not a mistake.
880        config.validate().expect("a blank shell must be accepted");
881    }
882
883    #[test]
884    fn set_value_accepts_a_shell_with_flags() {
885        let mut config = crate::config::load(None).unwrap();
886        config
887            .set_value("general.shell", "pwsh -NoProfile -Command")
888            .unwrap();
889        assert_eq!(config.general.shell, "pwsh -NoProfile -Command");
890        // The short alias reaches the same field.
891        config.set_value("shell", "bash -c").unwrap();
892        assert_eq!(config.general.shell, "bash -c");
893        config.validate().expect("a set shell must still validate");
894    }
895
896    #[test]
897    fn set_value_rejects_an_unspawnable_shell_and_keeps_the_old_one() {
898        let mut config = crate::config::load(None).unwrap();
899        config.set_value("shell", "bash -c").unwrap();
900
901        for rejected in ["\"pwsh -Command", "\"\""] {
902            let err = config.set_value("shell", rejected).unwrap_err();
903            assert!(
904                matches!(err, SetConfigError::InvalidValue { .. }),
905                "{rejected:?} should be an invalid value, got: {err}"
906            );
907        }
908        // A rejected value must not have been applied.
909        assert_eq!(config.general.shell, "bash -c");
910    }
911
912    #[test]
913    fn shell_pause_accepts_the_three_policies_and_rejects_anything_else() {
914        let mut config = crate::config::load(None).unwrap();
915        assert_eq!(
916            config.general.shell_pause, "always",
917            "the shipped default holds the screen, so command output can be read"
918        );
919
920        for accepted in ["never", "on_error", "ALWAYS"] {
921            config
922                .set_value("shell_pause", accepted)
923                .unwrap_or_else(|e| panic!("{accepted:?} should be accepted: {e}"));
924            assert_eq!(config.general.shell_pause, accepted.to_ascii_lowercase());
925            config.validate().expect("a set policy must still validate");
926        }
927
928        let err = config
929            .set_value("general.shell_pause", "sometimes")
930            .unwrap_err();
931        assert!(
932            matches!(err, SetConfigError::InvalidValue { .. }),
933            "an unknown policy should be rejected, got: {err}"
934        );
935        assert_eq!(
936            config.general.shell_pause, "always",
937            "a rejected value must not be applied"
938        );
939    }
940
941    #[test]
942    fn validate_rejects_an_unknown_shell_pause() {
943        let mut config = crate::config::load(None).unwrap();
944        config.general.shell_pause = "maybe".to_owned();
945        let err = config.validate().unwrap_err();
946        assert!(
947            format!("{err}").contains("general.shell_pause"),
948            "the error must name the key, got: {err}"
949        );
950    }
951
952    #[test]
953    fn validate_rejects_a_shell_with_an_unterminated_quote() {
954        let mut config = crate::config::load(None).unwrap();
955        config.general.shell = "\"pwsh -Command".to_owned();
956        let err = config.validate().unwrap_err();
957        assert!(
958            format!("{err}").contains("general.shell"),
959            "the error must name the key, got: {err}"
960        );
961    }
962
963    #[test]
964    fn set_value_rejects_unknown_keys_and_invalid_values() {
965        let mut config = crate::config::load(None).unwrap();
966
967        let unknown = config.set_value("keymap.navigation.fly", "f").unwrap_err();
968        assert!(matches!(unknown, SetConfigError::UnknownKey(_)));
969
970        let invalid = config.set_value("theme.directory", "nope").unwrap_err();
971        assert!(matches!(invalid, SetConfigError::InvalidValue { .. }));
972    }
973}