use anyhow::{Context, Result};
use crossterm::event::KeyCode;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::theme::ThemeConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppearanceConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_max_name_length")]
pub max_name_length: usize,
#[serde(default = "default_icons")]
pub icons: String,
#[serde(default = "default_show_cursor_path")]
pub show_cursor_path: bool,
#[serde(default)]
pub colors: ThemeConfig,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
theme: default_theme(),
max_name_length: default_max_name_length(),
icons: default_icons(),
show_cursor_path: default_show_cursor_path(),
colors: ThemeConfig::default(),
}
}
}
fn default_theme() -> String {
"default".to_string()
}
fn default_max_name_length() -> usize {
80
}
fn default_icons() -> String {
"unicode".to_string()
}
fn default_show_cursor_path() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorConfig {
#[serde(default = "default_show_hidden")]
pub show_hidden: bool,
#[serde(default = "default_follow_symlinks")]
pub follow_symlinks: bool,
#[serde(default = "default_double_click_timeout")]
pub double_click_timeout_ms: u64,
#[serde(default = "default_mouse_scroll_lines")]
pub mouse_scroll_lines: usize,
}
impl Default for BehaviorConfig {
fn default() -> Self {
Self {
show_hidden: default_show_hidden(),
follow_symlinks: default_follow_symlinks(),
double_click_timeout_ms: default_double_click_timeout(),
mouse_scroll_lines: default_mouse_scroll_lines(),
}
}
}
fn default_show_hidden() -> bool {
true
}
fn default_follow_symlinks() -> bool {
true
}
fn default_double_click_timeout() -> u64 {
800
}
fn default_mouse_scroll_lines() -> usize {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexConfig {
#[serde(default = "default_index_enabled")]
pub enabled: bool,
#[serde(default = "default_index_refresh_hours")]
pub refresh_hours: u64,
#[serde(default = "default_index_roots")]
pub roots: Vec<PathBuf>,
#[serde(default = "default_index_ignore_dirs")]
pub ignore_dirs: Vec<String>,
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
enabled: default_index_enabled(),
refresh_hours: default_index_refresh_hours(),
roots: default_index_roots(),
ignore_dirs: default_index_ignore_dirs(),
}
}
}
fn default_index_enabled() -> bool {
true
}
fn default_index_refresh_hours() -> u64 {
24
}
fn default_index_roots() -> Vec<PathBuf> {
dirs::home_dir().map(|h| vec![h]).unwrap_or_default()
}
fn default_index_ignore_dirs() -> Vec<String> {
[
".git",
"node_modules",
"target",
".cache",
".cargo",
".rustup",
"__pycache__",
".venv",
"venv",
".tox",
"dist",
"build",
".next",
".gradle",
".m2",
]
.into_iter()
.map(String::from)
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeybindingsConfig {
#[serde(default = "default_search_keys")]
pub search: Vec<String>,
#[serde(default = "default_create_bookmark_keys")]
pub create_bookmark: Vec<String>,
#[serde(default = "default_select_bookmark_keys")]
pub select_bookmark: Vec<String>,
#[serde(default = "default_select_disk_keys")]
pub select_disk: Vec<String>,
#[serde(default = "default_go_to_parent_keys")]
pub go_to_parent: Vec<String>,
#[serde(default = "default_copy_path_keys")]
pub copy_path: Vec<String>,
#[serde(default = "default_go_back_keys")]
pub go_back: Vec<String>,
#[serde(default = "default_quit_keys")]
pub quit: Vec<String>,
#[serde(default = "default_exit_keys")]
pub exit: Vec<String>,
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
search: default_search_keys(),
create_bookmark: default_create_bookmark_keys(),
select_bookmark: default_select_bookmark_keys(),
select_disk: default_select_disk_keys(),
go_to_parent: default_go_to_parent_keys(),
copy_path: default_copy_path_keys(),
go_back: default_go_back_keys(),
quit: default_quit_keys(),
exit: default_exit_keys(),
}
}
}
fn default_search_keys() -> Vec<String> {
vec!["/".to_string()]
}
fn default_create_bookmark_keys() -> Vec<String> {
vec!["m".to_string()]
}
fn default_select_bookmark_keys() -> Vec<String> {
vec!["'".to_string()]
}
fn default_select_disk_keys() -> Vec<String> {
vec!["d".to_string()]
}
fn default_go_to_parent_keys() -> Vec<String> {
vec!["u".to_string()]
}
fn default_copy_path_keys() -> Vec<String> {
vec!["c".to_string()]
}
fn default_go_back_keys() -> Vec<String> {
vec!["Backspace".to_string()]
}
fn default_quit_keys() -> Vec<String> {
vec!["q".to_string()]
}
fn default_exit_keys() -> Vec<String> {
vec!["Esc".to_string()]
}
impl KeybindingsConfig {
fn matches_key(&self, key: KeyCode, configured_keys: &[String]) -> bool {
let key_str = match key {
KeyCode::Char(c) => c.to_string(),
KeyCode::Esc => "Esc".to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::Delete => "Delete".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PageUp".to_string(),
KeyCode::PageDown => "PageDown".to_string(),
_ => return false,
};
configured_keys
.iter()
.any(|k| k.eq_ignore_ascii_case(&key_str))
}
pub fn is_search(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.search)
}
pub fn is_create_bookmark(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.create_bookmark)
}
pub fn is_select_bookmark(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.select_bookmark)
}
pub fn is_select_disk(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.select_disk)
}
pub fn is_go_to_parent(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.go_to_parent)
}
pub fn is_copy_path(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.copy_path)
}
pub fn is_go_back(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.go_back)
}
pub fn is_quit(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.quit)
}
pub fn is_exit(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.exit)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub appearance: AppearanceConfig,
#[serde(default)]
pub behavior: BehaviorConfig,
#[serde(default)]
pub keybindings: KeybindingsConfig,
#[serde(default)]
pub index: IndexConfig,
}
impl Config {
pub fn parse_color(color_str: &str) -> Color {
ThemeConfig::parse_color(color_str)
}
pub fn get_color(opt: &Option<String>) -> &str {
opt.as_ref()
.expect("Color should be resolved after config load")
}
fn parse_with_unknown_keys(content: &str) -> Result<(Self, Vec<String>), toml::de::Error> {
let de = toml::Deserializer::new(content);
let mut unknown_keys = Vec::new();
let config: Config = serde_ignored::deserialize(de, |path| {
unknown_keys.push(path.to_string());
})?;
Ok((config, unknown_keys))
}
pub fn from_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let (config, unknown_keys) = Self::parse_with_unknown_keys(&content)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
if !unknown_keys.is_empty() {
eprintln!(
"Note: {} not recognized in {} (ignored): {}",
if unknown_keys.len() == 1 {
"key"
} else {
"keys"
},
path.display(),
unknown_keys.join(", ")
);
eprintln!(" See CHANGELOG.md for renamed or removed config options.");
}
Ok(config)
}
pub fn global_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("bmrk").join("config.toml"))
}
pub fn load() -> anyhow::Result<Self> {
let mut config = Config::default();
if let Some(global_path) = Self::global_config_path() {
if !global_path.exists() {
let _ = Self::create_default_file(&global_path);
}
if global_path.exists() {
match Self::from_file(&global_path) {
Ok(global_config) => {
config = global_config;
}
Err(e) => {
anyhow::bail!(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
Configuration file error!\n\
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
\n\
Config file: {}\n\
\n\
Error details:\n\
{:#}\n\
\n\
To fix:\n\
1. Edit the config file and fix the syntax error\n\
2. Or delete the file - it will be recreated with defaults\n\
\n\
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
global_path.display(),
e
);
}
}
}
}
let preset = ThemeConfig::get_preset_theme(&config.appearance.theme);
let fallback = ThemeConfig::fallback_colors();
macro_rules! resolve_color {
($field:ident) => {
config.appearance.colors.$field = config
.appearance
.colors
.$field
.or_else(|| preset.as_ref().and_then(|p| p.$field.clone()))
.or_else(|| fallback.$field.clone());
};
}
resolve_color!(selected_color);
resolve_color!(directory_color);
resolve_color!(file_color);
resolve_color!(error_color);
resolve_color!(highlight_color);
resolve_color!(cursor_color);
resolve_color!(tree_cursor_color);
resolve_color!(tree_cursor_bg_color);
resolve_color!(header_path_color);
resolve_color!(header_hints_color);
Ok(config)
}
pub fn create_default_file(path: &Path) -> Result<()> {
let default_config = r#"# bmrk configuration file
# This file uses TOML format: https://toml.io
[appearance]
# Theme name - preset color schemes
# Available themes:
# "default" - Classic terminal colors (blue dirs, cyan selection)
# "gruvbox" - Warm, high contrast theme inspired by Gruvbox
# "nord" - Cold, muted colors inspired by Nord theme
# "tokyonight" - Modern dark theme with vibrant colors
# "dracula" - Popular dark theme with high contrast
# "obsidian" - Dark theme inspired by Obsidian app with subtle cursor
theme = "default"
# Maximum filename length in the tree before middle-truncation
# Example (with max_name_length = 20): "very_long_project_name.rs" -> "very_long..._name.rs"
# Set to 0 to disable truncation
max_name_length = 80
# Icon set used for the directory tree
# "unicode" - filled triangles: ▼ (expanded) ▶ (collapsed)
# "ascii" - plain characters: v (expanded) > (collapsed)
icons = "unicode"
# Show the full path of the currently selected item in the header (instead of just the tree
# root's path). Applies in Tree Navigation Mode and Quick Jump Mode alike.
show_cursor_path = true
# Custom theme colors (override preset theme)
[appearance.colors]
# Color formats: name (red, blue...), #RRGGBB hex, 0-255 indexed
#
# selected_color = "cyan"
# directory_color = "gray"
# file_color = "white"
# error_color = "gray"
# highlight_color = "yellow"
# cursor_color = "yellow"
# tree_cursor_color = "dim"
# tree_cursor_bg_color = "dim"
# header_path_color = "cyan"
# header_hints_color = "darkgray"
[behavior]
# Show hidden files (dotfiles)
show_hidden = true
# Follow symbolic links
follow_symlinks = true
# Double-click timeout in milliseconds
double_click_timeout_ms = 800
# Number of lines to scroll with mouse wheel
mouse_scroll_lines = 1
[keybindings]
# Key bindings — each entry is a list; multiple keys can trigger the same action.
# Supported key names: letters (a-z), symbols, Esc, Enter, Backspace, Tab,
# Up, Down, Left, Right, Home, End, PageUp, PageDown, Delete
search = ["/"]
create_bookmark = ["m"]
select_bookmark = ["'"]
select_disk = ["d"]
go_to_parent = ["u"]
copy_path = ["c"]
go_back = ["Backspace"]
quit = ["q"]
exit = ["Esc"]
[index]
# Background directory index — both Tab (quick jump) and / (search) consult this index as a
# fast synchronous lookup before falling back to a live disk scan of the current directory.
# Whether the index is built/used at all
# enabled = true
# Rebuild the index if it's older than this many hours
# refresh_hours = 24
# Root directories to index (defaults to the user's home directory)
# roots = ["/home/username"]
# Directory basenames to skip entirely while building the index.
# Setting this replaces the default list below rather than extending it.
# ignore_dirs = [".git", "node_modules", "target", ".cache", ".cargo", ".rustup",
# "__pycache__", ".venv", "venv", ".tox", "dist", "build", ".next", ".gradle", ".m2"]
"#;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
fs::write(path, default_config)
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.appearance.max_name_length, 80);
assert!(config.behavior.show_hidden);
assert!(config.index.enabled);
assert_eq!(config.index.refresh_hours, 24);
assert!(config
.index
.ignore_dirs
.contains(&"node_modules".to_string()));
assert!(config.appearance.show_cursor_path);
}
#[test]
fn test_create_default_file_output_parses_back() {
let tmp = std::env::temp_dir().join("bmrk_test_create_default_file");
let path = tmp.join("config.toml");
std::fs::create_dir_all(&tmp).unwrap();
Config::create_default_file(&path).unwrap();
let loaded = Config::from_file(&path).unwrap();
assert_eq!(loaded.index.enabled, default_index_enabled());
assert_eq!(loaded.index.refresh_hours, default_index_refresh_hours());
assert_eq!(loaded.index.roots, default_index_roots());
assert_eq!(loaded.index.ignore_dirs, default_index_ignore_dirs());
assert!(loaded.appearance.show_cursor_path);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_unknown_key_detected_but_does_not_fail_parsing() {
let toml_str = r#"
[appearance]
theme = "default"
old_removed_option = true
[behavior]
show_hidden = false
"#;
let (config, unknown_keys) = Config::parse_with_unknown_keys(toml_str).unwrap();
assert_eq!(unknown_keys, vec!["appearance.old_removed_option"]);
assert!(!config.behavior.show_hidden);
}
#[test]
fn test_default_file_has_no_unknown_keys() {
let tmp = std::env::temp_dir().join("bmrk_test_no_unknown_keys");
let path = tmp.join("config.toml");
std::fs::create_dir_all(&tmp).unwrap();
Config::create_default_file(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let (_, unknown_keys) = Config::parse_with_unknown_keys(&content).unwrap();
assert!(
unknown_keys.is_empty(),
"default config file should not contain any unrecognized keys: {:?}",
unknown_keys
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_color_parsing() {
assert!(matches!(ThemeConfig::parse_color("red"), Color::Red));
assert!(matches!(ThemeConfig::parse_color("blue"), Color::Blue));
assert!(matches!(
ThemeConfig::parse_color("#FF0000"),
Color::Rgb(255, 0, 0)
));
}
}