pub mod last_used;
pub mod persist;
pub mod schema;
pub mod terminal;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
pub use last_used::ConfigSource;
pub use schema::{
GeneralConfig, KeymapConfig, NavigationConfig, PluginsConfig, PreviewConfig, SetConfigError,
TerminalConfig, TerminalProfile, ThemeConfig, TrailConfig,
};
pub const DEFAULT_CONFIG_TOML: &str = include_str!("default.toml");
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("built-in default config is invalid: {0}")]
DefaultToml(String),
#[error("failed to read config {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{source}")]
Toml {
path: Option<PathBuf>,
source: TomlConfigError,
},
#[error("config validation error: {0}")]
Validation(#[from] SetConfigError),
}
#[derive(Debug, Error)]
#[error("{message}")]
pub struct TomlConfigError {
message: String,
}
pub fn load(path: Option<&Path>) -> Result<TrailConfig, ConfigError> {
let mut config = parse_toml(DEFAULT_CONFIG_TOML, None).map_err(|err| match err {
ConfigError::Toml { source, .. } => ConfigError::DefaultToml(source.to_string()),
other => other,
})?;
match path {
Some(path) => {
let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_owned(),
source,
})?;
let overrides = parse_overrides(&content, path)?;
overrides.apply_to(&mut config);
config.validate()?;
Ok(config)
}
None => Ok(config),
}
}
pub fn parse_toml(content: &str, path: Option<&Path>) -> Result<TrailConfig, ConfigError> {
let config = toml::from_str::<TrailConfig>(content).map_err(|err| ConfigError::Toml {
path: path.map(Path::to_owned),
source: toml_error(content, path, err),
})?;
config.validate()?;
Ok(config)
}
fn parse_overrides(content: &str, path: &Path) -> Result<ConfigOverrides, ConfigError> {
toml::from_str::<ConfigOverrides>(content).map_err(|err| ConfigError::Toml {
path: Some(path.to_owned()),
source: toml_error(content, Some(path), err),
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigOverrides {
general: Option<GeneralOverrides>,
navigation: Option<NavigationOverrides>,
preview: Option<PreviewOverrides>,
terminal: Option<TerminalOverrides>,
theme: Option<ThemeOverrides>,
keymap: Option<KeymapOverrides>,
plugins: Option<PluginsOverrides>,
}
impl ConfigOverrides {
fn apply_to(self, config: &mut TrailConfig) {
if let Some(general) = self.general {
general.apply_to(&mut config.general);
}
if let Some(navigation) = self.navigation {
navigation.apply_to(&mut config.navigation);
}
if let Some(preview) = self.preview {
preview.apply_to(&mut config.preview);
}
if let Some(terminal) = self.terminal {
terminal.apply_to(&mut config.terminal);
}
if let Some(theme) = self.theme {
theme.apply_to(&mut config.theme);
}
if let Some(keymap) = self.keymap {
keymap.apply_to(&mut config.keymap);
}
if let Some(plugins) = self.plugins {
plugins.apply_to(&mut config.plugins);
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct GeneralOverrides {
editor: Option<String>,
shell: Option<String>,
shell_pause: Option<String>,
text_sync_threshold_kb: Option<usize>,
delete_mode: Option<String>,
show_version: Option<bool>,
git_status_enabled: Option<bool>,
fs_watch_debounce_ms: Option<u64>,
}
impl GeneralOverrides {
fn apply_to(self, general: &mut GeneralConfig) {
if let Some(editor) = self.editor {
general.editor = editor;
}
if let Some(shell) = self.shell {
general.shell = shell;
}
if let Some(shell_pause) = self.shell_pause {
general.shell_pause = shell_pause;
}
if let Some(text_sync_threshold_kb) = self.text_sync_threshold_kb {
general.text_sync_threshold_kb = text_sync_threshold_kb;
}
if let Some(delete_mode) = self.delete_mode {
general.delete_mode = delete_mode;
}
if let Some(show_version) = self.show_version {
general.show_version = show_version;
}
if let Some(git_status_enabled) = self.git_status_enabled {
general.git_status_enabled = git_status_enabled;
}
if let Some(fs_watch_debounce_ms) = self.fs_watch_debounce_ms {
general.fs_watch_debounce_ms = fs_watch_debounce_ms;
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct NavigationOverrides {
sort_by: Option<String>,
sort_reverse: Option<bool>,
dirs_first: Option<bool>,
entry_details: Option<String>,
scroll_margin: Option<usize>,
}
impl NavigationOverrides {
fn apply_to(self, navigation: &mut NavigationConfig) {
if let Some(sort_by) = self.sort_by {
navigation.sort_by = sort_by;
}
if let Some(sort_reverse) = self.sort_reverse {
navigation.sort_reverse = sort_reverse;
}
if let Some(dirs_first) = self.dirs_first {
navigation.dirs_first = dirs_first;
}
if let Some(entry_details) = self.entry_details {
navigation.entry_details = entry_details;
}
if let Some(scroll_margin) = self.scroll_margin {
navigation.scroll_margin = scroll_margin;
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct PreviewOverrides {
image_protocol: Option<String>,
image_cell_width: Option<u16>,
image_cell_height: Option<u16>,
max_lines: Option<usize>,
}
impl PreviewOverrides {
fn apply_to(self, preview: &mut PreviewConfig) {
if let Some(image_protocol) = self.image_protocol {
preview.image_protocol = image_protocol;
}
if let Some(image_cell_width) = self.image_cell_width {
preview.image_cell_width = image_cell_width;
}
if let Some(image_cell_height) = self.image_cell_height {
preview.image_cell_height = image_cell_height;
}
if let Some(max_lines) = self.max_lines {
preview.max_lines = max_lines;
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct TerminalOverrides {
default_profile: Option<String>,
height: Option<u16>,
confirm_quit: Option<String>,
confirm_close: Option<String>,
profile: Option<Vec<TerminalProfile>>,
}
impl TerminalOverrides {
fn apply_to(self, terminal: &mut TerminalConfig) {
if let Some(default_profile) = self.default_profile {
terminal.default_profile = default_profile;
}
if let Some(height) = self.height {
terminal.height = height;
}
if let Some(confirm_quit) = self.confirm_quit {
terminal.confirm_quit = confirm_quit;
}
if let Some(confirm_close) = self.confirm_close {
terminal.confirm_close = confirm_close;
}
if let Some(profile) = self.profile {
terminal.profile = profile;
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ThemeOverrides {
foreground: Option<String>,
background: Option<String>,
border: Option<String>,
selection_fg: Option<String>,
selection_bg: Option<String>,
directory: Option<String>,
symlink: Option<String>,
hidden: Option<String>,
status_fg: Option<String>,
error: Option<String>,
search: Option<String>,
command: Option<String>,
git_clean: Option<String>,
git_dirty: Option<String>,
}
impl ThemeOverrides {
fn apply_to(self, theme: &mut ThemeConfig) {
if let Some(value) = self.foreground {
theme.foreground = value;
}
if let Some(value) = self.background {
theme.background = value;
}
if let Some(value) = self.border {
theme.border = value;
}
if let Some(value) = self.selection_fg {
theme.selection_fg = value;
}
if let Some(value) = self.selection_bg {
theme.selection_bg = value;
}
if let Some(value) = self.directory {
theme.directory = value;
}
if let Some(value) = self.symlink {
theme.symlink = value;
}
if let Some(value) = self.hidden {
theme.hidden = value;
}
if let Some(value) = self.status_fg {
theme.status_fg = value;
}
if let Some(value) = self.error {
theme.error = value;
}
if let Some(value) = self.search {
theme.search = value;
}
if let Some(value) = self.command {
theme.command = value;
}
if let Some(value) = self.git_clean {
theme.git_clean = value;
}
if let Some(value) = self.git_dirty {
theme.git_dirty = value;
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct KeymapOverrides {
navigation: Option<std::collections::HashMap<String, String>>,
search: Option<std::collections::HashMap<String, String>>,
terminal: Option<std::collections::HashMap<String, String>>,
}
impl KeymapOverrides {
fn apply_to(self, keymap: &mut KeymapConfig) {
if let Some(navigation) = self.navigation {
keymap.navigation.extend(navigation);
}
if let Some(search) = self.search {
keymap.search.extend(search);
}
if let Some(terminal) = self.terminal {
keymap.terminal.extend(terminal);
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct PluginsOverrides {
enabled: Option<Vec<String>>,
}
impl PluginsOverrides {
fn apply_to(self, plugins: &mut PluginsConfig) {
if let Some(enabled) = self.enabled {
plugins.enabled = enabled;
}
}
}
fn toml_error(content: &str, path: Option<&Path>, err: toml::de::Error) -> TomlConfigError {
let location = err
.span()
.map(|span| line_col(content, span.start))
.map(|(line, col)| format!("line {line}, column {col}"))
.unwrap_or_else(|| "unknown location".to_owned());
let prefix = path
.map(|p| format!("{}: ", p.display()))
.unwrap_or_default();
TomlConfigError {
message: format!("{prefix}config error at {location}: {err}"),
}
}
fn line_col(content: &str, byte_idx: usize) -> (usize, usize) {
let mut line = 1usize;
let mut col = 1usize;
for (idx, ch) in content.char_indices() {
if idx >= byte_idx {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_loads() {
let cfg = load(None).unwrap();
assert_eq!(cfg.general.text_sync_threshold_kb, 256);
assert!(cfg.general.git_status_enabled);
}
#[test]
fn user_config_overrides_defaults_partially() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r##"
[general]
text_sync_threshold_kb = 128
[theme]
directory = "#112233"
[keymap]
navigation = { move_down = "n" }
"##,
)
.unwrap();
let cfg = load(Some(&path)).unwrap();
assert_eq!(cfg.general.text_sync_threshold_kb, 128);
assert_eq!(cfg.general.editor, "nvim");
assert_eq!(cfg.theme.directory, "#112233");
assert_eq!(
cfg.keymap.navigation.get("move_down"),
Some(&"n".to_owned())
);
assert_eq!(cfg.keymap.navigation.get("move_up"), Some(&"k".to_owned()));
}
#[test]
fn user_config_can_override_part_of_the_navigation_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r#"
[navigation]
sort_by = "modified"
entry_details = "size"
"#,
)
.unwrap();
let cfg = load(Some(&path)).unwrap();
assert_eq!(cfg.general.editor, "nvim", "other sections are untouched");
assert_eq!(cfg.navigation.sort_by, "modified");
assert_eq!(cfg.navigation.entry_details, "size");
assert!(!cfg.navigation.sort_reverse);
assert!(cfg.navigation.dirs_first);
assert_eq!(cfg.navigation.scroll_margin, 3);
assert_eq!(
cfg.navigation.sort_settings(),
crate::app::sort::SortSettings {
by: crate::app::sort::SortBy::Modified,
reverse: false,
dirs_first: true,
}
);
}
#[test]
fn an_unknown_key_in_the_navigation_section_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r#"
[navigation]
sort_order = "size"
"#,
)
.unwrap();
assert!(load(Some(&path)).is_err());
}
#[test]
fn a_bad_sort_key_is_rejected_with_the_options_named() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r#"
[navigation]
sort_by = "created"
"#,
)
.unwrap();
let err = load(Some(&path)).unwrap_err().to_string();
assert!(err.contains("name, size, modified or extension"), "{err}");
}
#[test]
fn user_config_can_override_the_shell() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r#"
[general]
shell = 'pwsh -NoProfile -Command'
"#,
)
.unwrap();
let cfg = load(Some(&path)).unwrap();
assert_eq!(cfg.general.shell, "pwsh -NoProfile -Command");
assert_eq!(cfg.general.editor, "nvim");
}
#[test]
fn user_config_with_an_unspawnable_shell_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r#"
[general]
shell = '"pwsh -Command'
"#,
)
.unwrap();
let err = load(Some(&path)).unwrap_err().to_string();
assert!(err.contains("general.shell"), "got: {err}");
}
#[test]
fn unknown_key_is_rejected_with_line_context() {
let toml = r#"
[general]
editor = "vi"
text_sync_threshold_kb = 256
git_status_enabled = true
fs_watch_debounce_ms = 200
surprise = true
[theme]
foreground = "white"
background = "black"
border = "dark_gray"
selection_fg = "black"
selection_bg = "dark_gray"
directory = "blue"
symlink = "cyan"
hidden = "dark_gray"
status_fg = "dark_gray"
error = "red"
search = "yellow"
command = "cyan"
git_clean = "green"
git_dirty = "yellow"
[keymap]
navigation = {}
search = {}
[plugins]
enabled = []
"#;
let err = parse_toml(toml, None).unwrap_err().to_string();
assert!(err.contains("line"));
assert!(err.contains("surprise"));
}
#[test]
fn semantic_validation_rejects_bad_color() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r#"
[theme]
directory = "not-a-color"
"#,
)
.unwrap();
let err = load(Some(&path)).unwrap_err().to_string();
assert!(err.contains("theme.directory"));
assert!(err.contains("named color"));
}
#[test]
fn semantic_validation_rejects_unknown_keymap_action() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.toml");
std::fs::write(
&path,
r#"
[keymap]
navigation = { fly = "f" }
"#,
)
.unwrap();
let err = load(Some(&path)).unwrap_err().to_string();
assert!(err.contains("keymap.navigation.fly"));
}
}