use std::collections::HashMap;
use serde::Deserialize;
use thiserror::Error;
use super::terminal::{
parse_confirm, parse_terminal_chord, TERMINAL_HEIGHT_RANGE, TERMINAL_HEIGHT_REASON,
};
pub use super::terminal::{TerminalConfig, TerminalProfile};
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum SetConfigError {
#[error("unknown config key '{0}'")]
UnknownKey(String),
#[error("{key}: invalid value '{value}' ({reason})")]
InvalidValue {
key: String,
value: String,
reason: String,
},
}
const SHELL_SPEC_REASON: &str =
"must be a program optionally followed by flags, e.g. \"pwsh -NoProfile -Command\"; \
quote a path containing spaces";
const SHELL_PAUSE_REASON: &str = "must be always, on_error or never";
const DELETE_MODE_REASON: &str = "must be trash or permanent";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TrailConfig {
pub general: GeneralConfig,
pub navigation: NavigationConfig,
pub preview: PreviewConfig,
pub terminal: TerminalConfig,
pub theme: ThemeConfig,
pub keymap: KeymapConfig,
pub plugins: PluginsConfig,
}
impl TrailConfig {
pub fn validate(&self) -> Result<(), SetConfigError> {
if self.general.editor.trim().is_empty() {
return Err(invalid_value("general.editor", "", "must not be empty"));
}
if !self.general.shell.trim().is_empty()
&& crate::actions::shell_exec::split_shell_spec(&self.general.shell).is_none()
{
return Err(invalid_value(
"general.shell",
&self.general.shell,
SHELL_SPEC_REASON,
));
}
if crate::actions::shell_exec::ShellPause::parse(&self.general.shell_pause).is_none() {
return Err(invalid_value(
"general.shell_pause",
&self.general.shell_pause,
SHELL_PAUSE_REASON,
));
}
if crate::actions::fs_ops::DeleteMode::parse(&self.general.delete_mode).is_none() {
return Err(invalid_value(
"general.delete_mode",
&self.general.delete_mode,
DELETE_MODE_REASON,
));
}
if self.general.text_sync_threshold_kb == 0 {
return Err(invalid_value(
"general.text_sync_threshold_kb",
"0",
"must be greater than zero",
));
}
if crate::app::sort::SortBy::parse(&self.navigation.sort_by).is_none() {
return Err(invalid_value(
"navigation.sort_by",
&self.navigation.sort_by,
crate::app::sort::SORT_BY_REASON,
));
}
if crate::ui::nav_panel::EntryDetails::parse(&self.navigation.entry_details).is_none() {
return Err(invalid_value(
"navigation.entry_details",
&self.navigation.entry_details,
crate::ui::nav_panel::ENTRY_DETAILS_REASON,
));
}
if !self.preview.image_protocol.eq_ignore_ascii_case("auto")
&& crate::preview::graphics::ImageProtocol::parse(&self.preview.image_protocol)
.is_none()
{
return Err(invalid_value(
"preview.image_protocol",
&self.preview.image_protocol,
"must be auto, kitty, iterm2, sixel, halfblocks or none",
));
}
if self.preview.max_lines == 0 || self.preview.max_lines > PREVIEW_MAX_LINES_LIMIT {
return Err(invalid_value(
"preview.max_lines",
&self.preview.max_lines.to_string(),
"must be between 1 and 100000",
));
}
self.terminal.validate()?;
validate_color_value("theme.foreground", &self.theme.foreground)?;
validate_color_value("theme.background", &self.theme.background)?;
validate_color_value("theme.border", &self.theme.border)?;
validate_color_value("theme.selection_fg", &self.theme.selection_fg)?;
validate_color_value("theme.selection_bg", &self.theme.selection_bg)?;
validate_color_value("theme.directory", &self.theme.directory)?;
validate_color_value("theme.symlink", &self.theme.symlink)?;
validate_color_value("theme.hidden", &self.theme.hidden)?;
validate_color_value("theme.status_fg", &self.theme.status_fg)?;
validate_color_value("theme.error", &self.theme.error)?;
validate_color_value("theme.search", &self.theme.search)?;
validate_color_value("theme.command", &self.theme.command)?;
validate_color_value("theme.git_clean", &self.theme.git_clean)?;
validate_color_value("theme.git_dirty", &self.theme.git_dirty)?;
validate_keymap_table("keymap.navigation", &self.keymap.navigation, NAV_ACTIONS)?;
validate_keymap_table("keymap.search", &self.keymap.search, SEARCH_ACTIONS)?;
for (action, binding) in &self.keymap.search {
validate_search_binding(&format!("keymap.search.{action}"), binding)?;
}
for (action, binding) in &self.keymap.terminal {
let key = format!("keymap.terminal.{action}");
if !crate::terminal::TERMINAL_ACTIONS.contains(&action.as_str()) {
return Err(SetConfigError::UnknownKey(key));
}
parse_terminal_chord(&key, binding)?;
}
Ok(())
}
pub fn set_value(&mut self, key: &str, value: &str) -> Result<(), SetConfigError> {
match key {
"general.editor" | "editor" => {
let editor = value.trim();
if editor.is_empty() {
return Err(invalid_value(key, value, "must not be empty"));
}
self.general.editor = editor.to_owned();
}
"general.shell" | "shell" => {
if crate::actions::shell_exec::split_shell_spec(value).is_none() {
return Err(invalid_value(key, value, SHELL_SPEC_REASON));
}
self.general.shell = value.trim().to_owned();
}
"general.shell_pause" | "shell_pause" => {
if crate::actions::shell_exec::ShellPause::parse(value).is_none() {
return Err(invalid_value(key, value, SHELL_PAUSE_REASON));
}
self.general.shell_pause = value.trim().to_ascii_lowercase();
}
"general.delete_mode" | "delete_mode" => {
if crate::actions::fs_ops::DeleteMode::parse(value).is_none() {
return Err(invalid_value(key, value, DELETE_MODE_REASON));
}
self.general.delete_mode = value.trim().to_ascii_lowercase();
}
"general.text_sync_threshold_kb" | "text_sync_threshold_kb" => {
self.general.text_sync_threshold_kb = parse_positive_usize(key, value)?;
}
"general.show_version" | "show_version" => {
self.general.show_version = parse_bool(key, value)?;
}
"general.git_status_enabled" | "git_status_enabled" => {
self.general.git_status_enabled = parse_bool(key, value)?;
}
"general.fs_watch_debounce_ms" | "fs_watch_debounce_ms" => {
self.general.fs_watch_debounce_ms = parse_u64(key, value)?;
}
"navigation.sort_by" | "sort_by" => {
if crate::app::sort::SortBy::parse(value).is_none() {
return Err(invalid_value(key, value, crate::app::sort::SORT_BY_REASON));
}
self.navigation.sort_by = value.trim().to_ascii_lowercase();
}
"navigation.sort_reverse" | "sort_reverse" => {
self.navigation.sort_reverse = parse_bool(key, value)?;
}
"navigation.dirs_first" | "dirs_first" => {
self.navigation.dirs_first = parse_bool(key, value)?;
}
"navigation.entry_details" | "entry_details" => {
if crate::ui::nav_panel::EntryDetails::parse(value).is_none() {
return Err(invalid_value(
key,
value,
crate::ui::nav_panel::ENTRY_DETAILS_REASON,
));
}
self.navigation.entry_details = value.trim().to_ascii_lowercase();
}
"navigation.scroll_margin" | "scroll_margin" => {
self.navigation.scroll_margin = parse_usize(key, value)?;
}
"preview.image_protocol" | "image_protocol" => {
if !value.trim().eq_ignore_ascii_case("auto")
&& crate::preview::graphics::ImageProtocol::parse(value).is_none()
{
return Err(invalid_value(
key,
value,
"must be auto, kitty, iterm2, sixel, halfblocks or none",
));
}
self.preview.image_protocol = value.trim().to_ascii_lowercase();
}
"preview.image_cell_width" | "image_cell_width" => {
self.preview.image_cell_width = parse_cell_size(key, value)?;
}
"preview.image_cell_height" | "image_cell_height" => {
self.preview.image_cell_height = parse_cell_size(key, value)?;
}
"preview.max_lines" | "max_lines" => {
let max_lines = parse_positive_usize(key, value)?;
if max_lines > PREVIEW_MAX_LINES_LIMIT {
return Err(invalid_value(key, value, "must be at most 100000"));
}
self.preview.max_lines = max_lines;
}
"terminal.height" => {
let height = value
.trim()
.parse::<u16>()
.map_err(|_| invalid_value(key, value, TERMINAL_HEIGHT_REASON))?;
if !TERMINAL_HEIGHT_RANGE.contains(&height) {
return Err(invalid_value(key, value, TERMINAL_HEIGHT_REASON));
}
self.terminal.height = height;
}
"terminal.confirm_quit" | "confirm_quit" => {
self.terminal.confirm_quit = parse_confirm(key, value)?;
}
"terminal.confirm_close" | "confirm_close" => {
self.terminal.confirm_close = parse_confirm(key, value)?;
}
"terminal.default_profile" | "default_profile" => {
let name = value.trim();
if !self.terminal.has_profile(name) {
return Err(invalid_value(key, value, "names no [[terminal.profile]]"));
}
self.terminal.default_profile = name.to_owned();
}
"theme.foreground" => self.theme.foreground = parse_color_value(key, value)?,
"theme.background" => self.theme.background = parse_color_value(key, value)?,
"theme.border" => self.theme.border = parse_color_value(key, value)?,
"theme.selection_fg" => self.theme.selection_fg = parse_color_value(key, value)?,
"theme.selection_bg" => self.theme.selection_bg = parse_color_value(key, value)?,
"theme.directory" => self.theme.directory = parse_color_value(key, value)?,
"theme.symlink" => self.theme.symlink = parse_color_value(key, value)?,
"theme.hidden" => self.theme.hidden = parse_color_value(key, value)?,
"theme.status_fg" => self.theme.status_fg = parse_color_value(key, value)?,
"theme.error" => self.theme.error = parse_color_value(key, value)?,
"theme.search" => self.theme.search = parse_color_value(key, value)?,
"theme.command" => self.theme.command = parse_color_value(key, value)?,
"theme.git_clean" => self.theme.git_clean = parse_color_value(key, value)?,
"theme.git_dirty" => self.theme.git_dirty = parse_color_value(key, value)?,
key if key.starts_with("keymap.navigation.") => {
let action = key.trim_start_matches("keymap.navigation.");
if !NAV_ACTIONS.contains(&action) {
return Err(SetConfigError::UnknownKey(key.to_owned()));
}
self.keymap
.navigation
.insert(action.to_owned(), parse_key_binding(key, value)?);
}
key if key.starts_with("keymap.search.") => {
let action = key.trim_start_matches("keymap.search.");
if !SEARCH_ACTIONS.contains(&action) {
return Err(SetConfigError::UnknownKey(key.to_owned()));
}
let binding = parse_key_binding(key, value)?;
validate_search_binding(key, &binding)?;
self.keymap.search.insert(action.to_owned(), binding);
}
key if key.starts_with("keymap.terminal.") => {
let action = key.trim_start_matches("keymap.terminal.");
if !crate::terminal::TERMINAL_ACTIONS.contains(&action) {
return Err(SetConfigError::UnknownKey(key.to_owned()));
}
parse_terminal_chord(key, value)?;
self.keymap
.terminal
.insert(action.to_owned(), value.trim().to_owned());
}
_ => return Err(SetConfigError::UnknownKey(key.to_owned())),
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GeneralConfig {
pub editor: String,
pub shell: String,
pub shell_pause: String,
pub text_sync_threshold_kb: usize,
pub delete_mode: String,
pub show_version: bool,
pub git_status_enabled: bool,
pub fs_watch_debounce_ms: u64,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct NavigationConfig {
pub sort_by: String,
pub sort_reverse: bool,
pub dirs_first: bool,
pub entry_details: String,
pub scroll_margin: usize,
}
impl NavigationConfig {
pub fn sort_settings(&self) -> crate::app::sort::SortSettings {
crate::app::sort::SortSettings {
by: crate::app::sort::SortBy::parse(&self.sort_by).unwrap_or_default(),
reverse: self.sort_reverse,
dirs_first: self.dirs_first,
}
}
pub fn details(&self) -> crate::ui::nav_panel::EntryDetails {
crate::ui::nav_panel::EntryDetails::parse(&self.entry_details).unwrap_or_default()
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PreviewConfig {
pub image_protocol: String,
pub image_cell_width: u16,
pub image_cell_height: u16,
pub max_lines: usize,
}
pub const PREVIEW_MAX_LINES_LIMIT: usize = 100_000;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ThemeConfig {
pub foreground: String,
pub background: String,
pub border: String,
pub selection_fg: String,
pub selection_bg: String,
pub directory: String,
pub symlink: String,
pub hidden: String,
pub status_fg: String,
pub error: String,
pub search: String,
pub command: String,
pub git_clean: String,
pub git_dirty: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct KeymapConfig {
pub navigation: HashMap<String, String>,
pub search: HashMap<String, String>,
pub terminal: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PluginsConfig {
pub enabled: Vec<String>,
}
fn parse_cell_size(key: &str, value: &str) -> Result<u16, SetConfigError> {
value
.trim()
.parse()
.map_err(|_| invalid_value(key, value, "expected a whole number, or 0 to detect"))
}
fn parse_bool(key: &str, value: &str) -> Result<bool, SetConfigError> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => Ok(true),
"false" | "no" | "off" | "0" => Ok(false),
_ => Err(invalid_value(key, value, "expected true or false")),
}
}
fn parse_positive_usize(key: &str, value: &str) -> Result<usize, SetConfigError> {
let parsed = value
.trim()
.parse::<usize>()
.map_err(|_| invalid_value(key, value, "expected a positive integer"))?;
if parsed == 0 {
return Err(invalid_value(key, value, "must be greater than zero"));
}
Ok(parsed)
}
fn parse_usize(key: &str, value: &str) -> Result<usize, SetConfigError> {
value
.trim()
.parse::<usize>()
.map_err(|_| invalid_value(key, value, "expected a non-negative integer"))
}
fn parse_u64(key: &str, value: &str) -> Result<u64, SetConfigError> {
value
.trim()
.parse::<u64>()
.map_err(|_| invalid_value(key, value, "expected a non-negative integer"))
}
fn parse_color_value(key: &str, value: &str) -> Result<String, SetConfigError> {
let trimmed = value.trim();
validate_color_value(key, trimmed)?;
Ok(trimmed.to_owned())
}
fn parse_key_binding(key: &str, value: &str) -> Result<String, SetConfigError> {
let trimmed = value.trim();
validate_key_binding(key, trimmed)?;
Ok(trimmed.to_owned())
}
fn validate_keymap_table(
table: &str,
values: &HashMap<String, String>,
allowed_actions: &[&str],
) -> Result<(), SetConfigError> {
for (action, binding) in values {
if !allowed_actions.contains(&action.as_str()) {
return Err(SetConfigError::UnknownKey(format!("{table}.{action}")));
}
validate_key_binding(&format!("{table}.{action}"), binding)?;
}
Ok(())
}
fn validate_search_binding(key: &str, binding: &str) -> Result<(), SetConfigError> {
if binding.chars().count() == 1 {
return Err(invalid_value(
key,
binding,
"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",
));
}
Ok(())
}
fn validate_key_binding(key: &str, value: &str) -> Result<(), SetConfigError> {
if value.is_empty() {
return Err(invalid_value(key, value, "must not be empty"));
}
if value.chars().count() == 1 {
return Ok(());
}
let lower = value.to_ascii_lowercase();
let known = [
"enter",
"esc",
"backspace",
"tab",
"left",
"right",
"up",
"down",
"home",
"end",
];
if known.contains(&lower.as_str()) {
return Ok(());
}
if let Some(rest) = lower.strip_prefix("ctrl-") {
if rest.chars().count() == 1 {
return Ok(());
}
}
if let Some(rest) = lower.strip_prefix("shift-") {
if known.contains(&rest) {
return Ok(());
}
}
if value.chars().all(|ch| !ch.is_control()) {
return Ok(());
}
Err(invalid_value(
key,
value,
"expected a printable key, named key, or ctrl-x chord",
))
}
fn validate_color_value(key: &str, value: &str) -> Result<(), SetConfigError> {
let lower = value.trim().to_ascii_lowercase();
let named = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"dark_gray",
"dark_grey",
"darkgray",
"darkgrey",
"white",
"reset",
];
if named.contains(&lower.as_str()) {
return Ok(());
}
if lower.len() == 7
&& lower.starts_with('#')
&& lower[1..].chars().all(|ch| ch.is_ascii_hexdigit())
{
return Ok(());
}
Err(invalid_value(
key,
value,
"expected a named color or #rrggbb",
))
}
pub(super) fn invalid_value(key: &str, value: &str, reason: &str) -> SetConfigError {
SetConfigError::InvalidValue {
key: key.to_owned(),
value: value.to_owned(),
reason: reason.to_owned(),
}
}
const NAV_ACTIONS: &[&str] = &[
"move_down",
"move_up",
"jump_top",
"jump_bottom",
"enter_or_open",
"go_parent",
"history_back",
"history_forward",
"return_from_jump",
"refresh",
"toggle_hidden",
"sort_name",
"sort_size",
"sort_time",
"sort_extension",
"sort_reverse",
"sort_dirs_first",
"toggle_details",
"copy_absolute_path",
"copy_relative_path",
"copy_filename",
"copy_content",
"delete",
"toggle_mark",
"clear_marks",
"paste_copy",
"paste_move",
"enter_visual",
"repeat_command",
"enter_search",
"enter_command",
"quit",
"open_with_os",
"new_tab",
"close_tab",
"switch_tab_next",
"switch_tab_prev",
"preview_scroll_down",
"preview_scroll_up",
"preview_page_down",
"preview_page_up",
"preview_scroll_top",
"preview_scroll_bottom",
];
const SEARCH_ACTIONS: &[&str] = &["exit", "confirm", "move_down", "move_up", "delete_char"];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_bindings_reject_bare_characters() {
let mut config = crate::config::load(None).unwrap();
config
.keymap
.search
.insert("move_down".to_owned(), "j".to_owned());
let err = config.validate().unwrap_err();
assert!(
format!("{err}").contains("single character"),
"expected the shadowing explanation, got: {err}"
);
}
#[test]
fn set_value_rejects_a_bare_character_search_binding() {
let mut config = crate::config::load(None).unwrap();
let err = config.set_value("keymap.search.move_up", "k").unwrap_err();
assert!(format!("{err}").contains("single character"));
assert_eq!(config.keymap.search.get("move_up"), Some(&"up".to_owned()));
}
#[test]
fn set_value_accepts_and_bounds_preview_max_lines() {
let mut config = crate::config::load(None).unwrap();
config.set_value("preview.max_lines", "8000").unwrap();
assert_eq!(config.preview.max_lines, 8000);
config.set_value("max_lines", "500").unwrap();
assert_eq!(config.preview.max_lines, 500);
for rejected in ["0", "200000"] {
assert!(
config.set_value("preview.max_lines", rejected).is_err(),
"{rejected} must be rejected"
);
}
assert_eq!(config.preview.max_lines, 500);
}
#[test]
fn preview_scroll_bindings_are_configurable() {
let mut config = crate::config::load(None).unwrap();
config
.set_value("keymap.navigation.preview_scroll_down", "J")
.unwrap();
config
.set_value("keymap.navigation.preview_scroll_bottom", "shift-end")
.unwrap();
assert_eq!(
config.keymap.navigation.get("preview_scroll_bottom"),
Some(&"shift-end".to_owned())
);
config.validate().expect("shipped defaults must validate");
}
#[test]
fn search_bindings_accept_named_and_modified_keys() {
let mut config = crate::config::load(None).unwrap();
for binding in ["down", "ctrl-n", "enter", "esc"] {
config
.set_value("keymap.search.move_down", binding)
.unwrap_or_else(|e| panic!("{binding} should be allowed: {e}"));
}
}
#[test]
fn set_value_updates_general_and_keymap_values() {
let mut config = crate::config::load(None).unwrap();
config
.set_value("git_status_enabled", "false")
.expect("bool setting should parse");
config
.set_value("keymap.navigation.move_down", "n")
.expect("keymap setting should parse");
assert!(!config.general.git_status_enabled);
assert_eq!(
config.keymap.navigation.get("move_down"),
Some(&"n".to_owned())
);
}
#[test]
fn the_shipped_shell_default_is_blank_and_means_platform_default() {
let config = crate::config::load(None).unwrap();
assert_eq!(config.general.shell, "");
config.validate().expect("a blank shell must be accepted");
}
#[test]
fn set_value_accepts_a_shell_with_flags() {
let mut config = crate::config::load(None).unwrap();
config
.set_value("general.shell", "pwsh -NoProfile -Command")
.unwrap();
assert_eq!(config.general.shell, "pwsh -NoProfile -Command");
config.set_value("shell", "bash -c").unwrap();
assert_eq!(config.general.shell, "bash -c");
config.validate().expect("a set shell must still validate");
}
#[test]
fn set_value_rejects_an_unspawnable_shell_and_keeps_the_old_one() {
let mut config = crate::config::load(None).unwrap();
config.set_value("shell", "bash -c").unwrap();
for rejected in ["\"pwsh -Command", "\"\""] {
let err = config.set_value("shell", rejected).unwrap_err();
assert!(
matches!(err, SetConfigError::InvalidValue { .. }),
"{rejected:?} should be an invalid value, got: {err}"
);
}
assert_eq!(config.general.shell, "bash -c");
}
#[test]
fn shell_pause_accepts_the_three_policies_and_rejects_anything_else() {
let mut config = crate::config::load(None).unwrap();
assert_eq!(
config.general.shell_pause, "always",
"the shipped default holds the screen, so command output can be read"
);
for accepted in ["never", "on_error", "ALWAYS"] {
config
.set_value("shell_pause", accepted)
.unwrap_or_else(|e| panic!("{accepted:?} should be accepted: {e}"));
assert_eq!(config.general.shell_pause, accepted.to_ascii_lowercase());
config.validate().expect("a set policy must still validate");
}
let err = config
.set_value("general.shell_pause", "sometimes")
.unwrap_err();
assert!(
matches!(err, SetConfigError::InvalidValue { .. }),
"an unknown policy should be rejected, got: {err}"
);
assert_eq!(
config.general.shell_pause, "always",
"a rejected value must not be applied"
);
}
#[test]
fn validate_rejects_an_unknown_shell_pause() {
let mut config = crate::config::load(None).unwrap();
config.general.shell_pause = "maybe".to_owned();
let err = config.validate().unwrap_err();
assert!(
format!("{err}").contains("general.shell_pause"),
"the error must name the key, got: {err}"
);
}
#[test]
fn validate_rejects_a_shell_with_an_unterminated_quote() {
let mut config = crate::config::load(None).unwrap();
config.general.shell = "\"pwsh -Command".to_owned();
let err = config.validate().unwrap_err();
assert!(
format!("{err}").contains("general.shell"),
"the error must name the key, got: {err}"
);
}
#[test]
fn set_value_rejects_unknown_keys_and_invalid_values() {
let mut config = crate::config::load(None).unwrap();
let unknown = config.set_value("keymap.navigation.fly", "f").unwrap_err();
assert!(matches!(unknown, SetConfigError::UnknownKey(_)));
let invalid = config.set_value("theme.directory", "nope").unwrap_err();
assert!(matches!(invalid, SetConfigError::InvalidValue { .. }));
}
}