use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct Config {
pub theme: ThemeConfig,
pub ui: UiConfig,
pub keybindings: KeybindingsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
pub default: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UiConfig {
pub max_rows: usize,
pub column_width: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KeybindingsConfig {
pub profile: String,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub custom: HashMap<String, String>,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
default: "Default".to_string(),
}
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
max_rows: 50,
column_width: 30,
}
}
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
profile: "default".to_string(),
custom: HashMap::new(),
}
}
}
impl Config {
pub fn load(custom_path: Option<PathBuf>) -> Result<Self> {
let config_path = if let Some(path) = custom_path {
path
} else {
Self::default_config_path()?
};
if !config_path.exists() {
return Ok(Self::default());
}
let config_str = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let config: Config = toml::from_str(&config_str)
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
Ok(config)
}
pub fn default_config_path() -> Result<PathBuf> {
if let Some(home) = dirs::home_dir() {
let xdg_path = home.join(".config").join("xleak").join("config.toml");
if xdg_path.exists() {
return Ok(xdg_path);
}
}
let config_dir = dirs::config_dir()
.context("Failed to determine config directory")?
.join("xleak");
Ok(config_dir.join("config.toml"))
}
#[allow(dead_code)]
pub fn create_example() -> Result<PathBuf> {
let config_path = Self::default_config_path()?;
let config_dir = config_path
.parent()
.context("Failed to get config directory")?;
fs::create_dir_all(config_dir).with_context(|| {
format!(
"Failed to create config directory: {}",
config_dir.display()
)
})?;
let example = Self::example_toml();
fs::write(&config_path, example).with_context(|| {
format!("Failed to write example config: {}", config_path.display())
})?;
Ok(config_path)
}
fn example_toml() -> String {
r#"# xleak configuration file
# Location: $XDG_CONFIG_HOME/xleak/config.toml (usually ~/.config/xleak/config.toml)
[theme]
# Default theme to use on startup
# Options: "Default", "Dracula", "Solarized Dark", "Solarized Light", "GitHub Dark", "Nord"
default = "Default"
[ui]
# Default maximum rows to display in non-interactive mode (0 = all)
max_rows = 50
# Default maximum column width in characters
column_width = 30
[keybindings]
# Keybinding profile: "default" or "vim"
profile = "default"
# Custom keybindings (optional - overrides profile)
# Uncomment and modify to customize individual keys
# [keybindings.custom]
# quit = "q"
# help = "?"
# theme_toggle = "t"
# search = "/"
# next_match = "n"
# prev_match = "N"
# copy_cell = "c"
# copy_row = "C"
# jump = "Ctrl+g"
# show_cell_detail = "Enter"
# VIM-style navigation (when profile = "vim")
# up = "k"
# down = "j"
# left = "h"
# right = "l"
# page_up = "Ctrl+u"
# page_down = "Ctrl+d"
# jump_to_top = "g"
# jump_to_bottom = "G"
# jump_to_row_start = "0"
# jump_to_row_end = "$"
"#
.to_string()
}
pub fn get_keybinding(&self, action: &str) -> Option<(KeyCode, KeyModifiers)> {
if let Some(key_str) = self.keybindings.custom.get(action) {
return parse_key_string(key_str);
}
match self.keybindings.profile.as_str() {
"vim" => get_vim_keybinding(action),
_ => get_default_keybinding(action),
}
}
}
fn parse_key_string(s: &str) -> Option<(KeyCode, KeyModifiers)> {
let parts: Vec<&str> = s.split('+').collect();
let mut modifiers = KeyModifiers::empty();
let key_part = parts.last()?;
for part in &parts[..parts.len() - 1] {
match part.to_lowercase().as_str() {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"alt" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
_ => return None,
}
}
let code = match *key_part {
k if k.eq_ignore_ascii_case("enter") => KeyCode::Enter,
k if k.eq_ignore_ascii_case("esc") => KeyCode::Esc,
k if k.eq_ignore_ascii_case("escape") => KeyCode::Esc,
k if k.eq_ignore_ascii_case("tab") => KeyCode::Tab,
k if k.eq_ignore_ascii_case("backtab") => KeyCode::BackTab,
k if k.eq_ignore_ascii_case("backspace") => KeyCode::Backspace,
k if k.eq_ignore_ascii_case("delete") => KeyCode::Delete,
k if k.eq_ignore_ascii_case("del") => KeyCode::Delete,
k if k.eq_ignore_ascii_case("insert") => KeyCode::Insert,
k if k.eq_ignore_ascii_case("ins") => KeyCode::Insert,
k if k.eq_ignore_ascii_case("home") => KeyCode::Home,
k if k.eq_ignore_ascii_case("end") => KeyCode::End,
k if k.eq_ignore_ascii_case("pageup") => KeyCode::PageUp,
k if k.eq_ignore_ascii_case("pgup") => KeyCode::PageUp,
k if k.eq_ignore_ascii_case("pagedown") => KeyCode::PageDown,
k if k.eq_ignore_ascii_case("pgdn") => KeyCode::PageDown,
k if k.eq_ignore_ascii_case("up") => KeyCode::Up,
k if k.eq_ignore_ascii_case("down") => KeyCode::Down,
k if k.eq_ignore_ascii_case("left") => KeyCode::Left,
k if k.eq_ignore_ascii_case("right") => KeyCode::Right,
s if s.len() == 1 => KeyCode::Char(s.chars().next()?),
_ => return None,
};
Some((code, modifiers))
}
fn get_default_keybinding(action: &str) -> Option<(KeyCode, KeyModifiers)> {
let binding = match action {
"quit" => ("q", KeyModifiers::empty()),
"help" => ("?", KeyModifiers::SHIFT),
"theme_toggle" => ("t", KeyModifiers::empty()),
"search" => ("/", KeyModifiers::empty()),
"next_match" => ("n", KeyModifiers::empty()),
"prev_match" => ("N", KeyModifiers::SHIFT),
"copy_cell" => ("c", KeyModifiers::empty()),
"copy_row" => ("C", KeyModifiers::SHIFT),
"jump" => ("g", KeyModifiers::CONTROL),
"show_cell_detail" => ("Enter", KeyModifiers::empty()),
"next_sheet" => ("Tab", KeyModifiers::empty()),
"prev_sheet" => ("Tab", KeyModifiers::SHIFT),
"up" => ("Up", KeyModifiers::empty()),
"down" => ("Down", KeyModifiers::empty()),
"left" => ("Left", KeyModifiers::empty()),
"right" => ("Right", KeyModifiers::empty()),
"page_up" => ("PageUp", KeyModifiers::empty()),
"page_down" => ("PageDown", KeyModifiers::empty()),
"jump_to_top" => ("Home", KeyModifiers::CONTROL),
"jump_to_bottom" => ("End", KeyModifiers::CONTROL),
"jump_to_row_start" => ("Home", KeyModifiers::empty()),
"jump_to_row_end" => ("End", KeyModifiers::empty()),
_ => return None,
};
parse_key_string(binding.0).map(|(code, _)| (code, binding.1))
}
fn get_vim_keybinding(action: &str) -> Option<(KeyCode, KeyModifiers)> {
let binding = match action {
"up" => ("k", KeyModifiers::empty()),
"down" => ("j", KeyModifiers::empty()),
"left" => ("h", KeyModifiers::empty()),
"right" => ("l", KeyModifiers::empty()),
"page_up" => ("u", KeyModifiers::CONTROL),
"page_down" => ("d", KeyModifiers::CONTROL),
"jump_to_top" => ("g", KeyModifiers::empty()),
"jump_to_bottom" => ("G", KeyModifiers::SHIFT),
"jump_to_row_start" => ("0", KeyModifiers::empty()),
"jump_to_row_end" => ("$", KeyModifiers::SHIFT),
"quit" => ("q", KeyModifiers::empty()),
"copy_cell" => ("y", KeyModifiers::empty()),
"copy_row" => ("Y", KeyModifiers::SHIFT),
_ => return get_default_keybinding(action),
};
parse_key_string(binding.0).map(|(code, _)| (code, binding.1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_key_string() {
assert_eq!(
parse_key_string("q"),
Some((KeyCode::Char('q'), KeyModifiers::empty()))
);
assert_eq!(
parse_key_string("Ctrl+g"),
Some((KeyCode::Char('g'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_key_string("Enter"),
Some((KeyCode::Enter, KeyModifiers::empty()))
);
assert_eq!(
parse_key_string("Shift+Tab"),
Some((KeyCode::Tab, KeyModifiers::SHIFT))
);
}
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.theme.default, "Default");
assert_eq!(config.ui.max_rows, 50);
assert_eq!(config.keybindings.profile, "default");
}
#[test]
fn test_vim_keybindings() {
assert_eq!(
get_vim_keybinding("up"),
Some((KeyCode::Char('k'), KeyModifiers::empty()))
);
assert_eq!(
get_vim_keybinding("down"),
Some((KeyCode::Char('j'), KeyModifiers::empty()))
);
}
#[test]
fn test_toml_parsing_unix_line_endings() {
let config_str = "[theme]\ndefault = \"Dracula\"\n\n[ui]\nmax_rows = 100\n\n[keybindings]\nprofile = \"vim\"";
let config: Config = toml::from_str(config_str).expect("Failed to parse TOML");
assert_eq!(config.theme.default, "Dracula");
assert_eq!(config.ui.max_rows, 100);
assert_eq!(config.keybindings.profile, "vim");
}
#[test]
fn test_toml_parsing_windows_line_endings() {
let config_str = "[theme]\r\ndefault = \"Nord\"\r\n\r\n[ui]\r\nmax_rows = 200\r\n\r\n[keybindings]\r\nprofile = \"default\"";
let config: Config = toml::from_str(config_str).expect("Failed to parse TOML");
assert_eq!(config.theme.default, "Nord");
assert_eq!(config.ui.max_rows, 200);
assert_eq!(config.keybindings.profile, "default");
}
#[test]
fn test_toml_parsing_mixed_line_endings() {
let config_str = "[theme]\r\ndefault = \"GitHub Dark\"\n\n[ui]\r\nmax_rows = 75\n[keybindings]\r\nprofile = \"vim\"";
let config: Config = toml::from_str(config_str).expect("Failed to parse TOML");
assert_eq!(config.theme.default, "GitHub Dark");
assert_eq!(config.ui.max_rows, 75);
assert_eq!(config.keybindings.profile, "vim");
}
#[test]
fn test_theme_name_case_insensitive() {
let config_str = "[theme]\ndefault = \"dracula\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "dracula");
let config_str = "[theme]\ndefault = \"DRACULA\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "DRACULA");
let config_str = "[theme]\ndefault = \"Dracula\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "Dracula");
}
#[test]
fn test_theme_name_with_spaces() {
let config_str = "[theme]\ndefault = \"Solarized Dark\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "Solarized Dark");
let config_str = "[theme]\ndefault = \"GitHub Dark\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "GitHub Dark");
}
#[test]
fn test_invalid_theme_stored_as_is() {
let config_str = "[theme]\ndefault = \"NonexistentTheme\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "NonexistentTheme");
}
#[test]
fn test_custom_keybindings_override_profile() {
let config_str = r#"
[keybindings]
profile = "default"
[keybindings.custom]
quit = "x"
search = "?"
"#;
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(
config.get_keybinding("quit"),
Some((KeyCode::Char('x'), KeyModifiers::empty()))
);
assert_eq!(
config.get_keybinding("search"),
Some((KeyCode::Char('?'), KeyModifiers::empty()))
);
assert_eq!(
config.get_keybinding("help"),
Some((KeyCode::Char('?'), KeyModifiers::SHIFT))
);
}
#[test]
fn test_vim_profile_with_custom_overrides() {
let config_str = r#"
[keybindings]
profile = "vim"
[keybindings.custom]
quit = "x"
page_up = "Ctrl+b"
"#;
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(
config.get_keybinding("quit"),
Some((KeyCode::Char('x'), KeyModifiers::empty()))
);
assert_eq!(
config.get_keybinding("page_up"),
Some((KeyCode::Char('b'), KeyModifiers::CONTROL))
);
assert_eq!(
config.get_keybinding("up"),
Some((KeyCode::Char('k'), KeyModifiers::empty()))
);
assert_eq!(
config.get_keybinding("down"),
Some((KeyCode::Char('j'), KeyModifiers::empty()))
);
}
#[test]
fn test_get_keybinding_returns_none_for_unknown_action() {
let config = Config::default();
assert_eq!(config.get_keybinding("nonexistent_action"), None);
assert_eq!(config.get_keybinding(""), None);
assert_eq!(config.get_keybinding("random_string_12345"), None);
}
#[test]
fn test_parse_key_multiple_modifiers() {
assert_eq!(
parse_key_string("Ctrl+Shift+Tab"),
Some((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT))
);
assert_eq!(
parse_key_string("Ctrl+Alt+g"),
Some((
KeyCode::Char('g'),
KeyModifiers::CONTROL | KeyModifiers::ALT
))
);
}
#[test]
fn test_parse_key_case_insensitive_modifiers() {
assert_eq!(
parse_key_string("ctrl+g"),
Some((KeyCode::Char('g'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_key_string("CTRL+g"),
Some((KeyCode::Char('g'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_key_string("Ctrl+g"),
Some((KeyCode::Char('g'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_key_string("shift+tab"),
Some((KeyCode::Tab, KeyModifiers::SHIFT))
);
assert_eq!(
parse_key_string("SHIFT+TAB"),
Some((KeyCode::Tab, KeyModifiers::SHIFT))
);
}
#[test]
fn test_parse_key_invalid_strings() {
assert_eq!(parse_key_string(""), None);
assert_eq!(parse_key_string("InvalidKey"), None);
assert_eq!(parse_key_string("Ctrl+"), None);
assert_eq!(parse_key_string("+g"), None);
assert_eq!(parse_key_string("Ctrl+InvalidKey"), None);
assert_eq!(parse_key_string("Unknown+g"), None);
}
#[test]
fn test_vim_profile_falls_back_to_default() {
let config_str = "[keybindings]\nprofile = \"vim\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(
config.get_keybinding("up"),
Some((KeyCode::Char('k'), KeyModifiers::empty()))
);
assert_eq!(
config.get_keybinding("help"),
Some((KeyCode::Char('?'), KeyModifiers::SHIFT))
);
assert_eq!(
config.get_keybinding("theme_toggle"),
Some((KeyCode::Char('t'), KeyModifiers::empty()))
);
assert_eq!(
config.get_keybinding("search"),
Some((KeyCode::Char('/'), KeyModifiers::empty()))
);
}
}