use anyhow::Context;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub general: GeneralConfig,
pub ui: UiConfig,
pub keybindings: KeybindingsConfig,
pub smart_context: SmartContextConfig,
pub auto_key_setup: AutoKeySetupConfig,
pub update: UpdateConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GeneralConfig {
pub refresh_interval: u64,
pub default_shell: String,
pub ssh_command: String,
pub max_concurrent_connections: usize,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
refresh_interval: 30,
default_shell: String::from("/bin/bash"),
ssh_command: String::from("ssh"),
max_concurrent_connections: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UiConfig {
pub theme: String,
pub show_ip: bool,
pub show_uptime: bool,
pub card_layout: String,
pub border_style: String,
}
impl UiConfig {
pub fn available_themes() -> &'static [&'static str] {
&["default", "dracula", "nord", "gruvbox"]
}
pub fn is_valid_theme(name: &str) -> bool {
Self::available_themes().contains(&name)
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
theme: String::from("default"),
show_ip: true,
show_uptime: true,
card_layout: String::from("grid"),
border_style: String::from("rounded"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KeybindingsConfig {
pub quit: String,
pub search: String,
pub connect: String,
pub dashboard: String,
pub file_manager: String,
pub snippets: String,
pub next_screen: String,
pub next_tab: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SmartContextConfig {
pub enabled: bool,
pub scan_interval: u64,
}
impl Default for SmartContextConfig {
fn default() -> Self {
Self {
enabled: true,
scan_interval: 300, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AutoKeySetupConfig {
pub enabled: bool,
pub suggest_on_password_auth: bool,
pub disable_password_auth: bool,
pub key_type: String,
pub key_directory: String,
pub backup_sshd_config: bool,
pub confirm_before_disable: bool,
}
impl Default for AutoKeySetupConfig {
fn default() -> Self {
Self {
enabled: true,
suggest_on_password_auth: true,
disable_password_auth: true,
key_type: String::from("ed25519"),
key_directory: String::from("~/.ssh"),
backup_sshd_config: true,
confirm_before_disable: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UpdateConfig {
pub check_on_startup: bool,
pub skip_version: String,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
check_on_startup: true,
skip_version: String::new(),
}
}
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
quit: String::from("q"),
search: String::from("/"),
connect: String::from("Enter"),
dashboard: String::from("F1"),
file_manager: String::from("F2"),
snippets: String::from("F3"),
next_screen: String::from("Tab"),
next_tab: String::from("Tab"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct KeyBind {
pub code: KeyCode,
pub ctrl: bool,
}
impl KeyBind {
pub fn matches(&self, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
key.code == self.code && ctrl == self.ctrl
}
}
#[derive(Debug, Clone)]
pub struct ParsedKeybindings {
pub quit: KeyCode,
pub search: KeyCode,
pub connect: KeyCode,
pub dashboard: KeyCode,
pub file_manager: KeyCode,
pub snippets: KeyCode,
pub next_screen: KeyBind,
pub next_tab: KeyBind,
}
impl ParsedKeybindings {
pub fn from_config(cfg: &KeybindingsConfig) -> Self {
let defaults = KeybindingsConfig::default();
Self {
quit: parse_keycode(&cfg.quit)
.unwrap_or_else(|| parse_keycode(&defaults.quit).expect("default quit")),
search: parse_keycode(&cfg.search)
.unwrap_or_else(|| parse_keycode(&defaults.search).expect("default search")),
connect: parse_keycode(&cfg.connect)
.unwrap_or_else(|| parse_keycode(&defaults.connect).expect("default connect")),
dashboard: parse_keycode(&cfg.dashboard)
.unwrap_or_else(|| parse_keycode(&defaults.dashboard).expect("default dashboard")),
file_manager: parse_keycode(&cfg.file_manager).unwrap_or_else(|| {
parse_keycode(&defaults.file_manager).expect("default file_manager")
}),
snippets: parse_keycode(&cfg.snippets)
.unwrap_or_else(|| parse_keycode(&defaults.snippets).expect("default snippets")),
next_screen: parse_keybind(&cfg.next_screen).unwrap_or_else(|| {
parse_keybind(&defaults.next_screen).expect("default next_screen")
}),
next_tab: parse_keybind(&cfg.next_tab)
.unwrap_or_else(|| parse_keybind(&defaults.next_tab).expect("default next_tab")),
}
}
}
impl Default for ParsedKeybindings {
fn default() -> Self {
Self::from_config(&KeybindingsConfig::default())
}
}
pub fn parse_keycode(s: &str) -> Option<KeyCode> {
match s {
"Enter" => Some(KeyCode::Enter),
"Esc" | "Escape" => Some(KeyCode::Esc),
"Tab" => Some(KeyCode::Tab),
"Backtab" | "BackTab" | "ShiftTab" => Some(KeyCode::BackTab),
"Backspace" | "BS" => Some(KeyCode::Backspace),
"Delete" | "Del" => Some(KeyCode::Delete),
"Up" => Some(KeyCode::Up),
"Down" => Some(KeyCode::Down),
"Left" => Some(KeyCode::Left),
"Right" => Some(KeyCode::Right),
"Home" => Some(KeyCode::Home),
"End" => Some(KeyCode::End),
"PageUp" => Some(KeyCode::PageUp),
"PageDown" => Some(KeyCode::PageDown),
f if f.starts_with('F') || f.starts_with('f') => f[1..].parse::<u8>().ok().map(KeyCode::F),
c if c.chars().count() == 1 => c.chars().next().map(KeyCode::Char),
_ => None,
}
}
pub fn parse_keybind(s: &str) -> Option<KeyBind> {
if let Some(rest) = s
.strip_prefix("Ctrl+")
.or_else(|| s.strip_prefix("ctrl+"))
.or_else(|| s.strip_prefix("CTRL+"))
{
if rest.chars().count() == 1 {
return rest.chars().next().map(|c| KeyBind {
code: KeyCode::Char(c.to_ascii_lowercase()),
ctrl: true,
});
}
if let Some(code) = parse_keycode(rest) {
return Some(KeyBind { code, ctrl: true });
}
return None;
}
parse_keycode(s).map(|code| KeyBind { code, ctrl: false })
}
pub fn load_app_config(path: Option<&std::path::Path>) -> anyhow::Result<AppConfig> {
use crate::utils::platform;
let config_path = match path {
Some(p) => p.to_path_buf(),
None => match platform::app_config_path() {
Some(p) => p,
None => return Ok(AppConfig::default()),
},
};
if !config_path.exists() {
return Ok(AppConfig::default());
}
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
let config: AppConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse config: {}", config_path.display()))?;
Ok(config)
}
fn persist_config<F: FnOnce(&mut AppConfig)>(mutator: F) -> anyhow::Result<()> {
use crate::utils::platform;
let config_path = match platform::app_config_path() {
Some(p) => p,
None => anyhow::bail!("Cannot determine config path for this platform"),
};
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let mut config = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
toml::from_str::<AppConfig>(&content)
.with_context(|| format!("Failed to parse config: {}", config_path.display()))?
} else {
AppConfig::default()
};
mutator(&mut config);
let content = toml::to_string_pretty(&config).context("Failed to serialize config")?;
std::fs::write(&config_path, content)
.with_context(|| format!("Failed to write config: {}", config_path.display()))?;
Ok(())
}
pub fn save_theme_to_config(theme_name: &str) -> anyhow::Result<()> {
persist_config(|config| config.ui.theme = theme_name.to_string())
}
pub fn save_update_config(update: &UpdateConfig) -> anyhow::Result<()> {
let update = update.clone();
persist_config(move |config| config.update = update)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_keybindings_parse() {
let _kb = ParsedKeybindings::default();
}
#[test]
fn parse_ctrl_combo() {
let kb = parse_keybind("Ctrl+T").unwrap();
assert!(kb.ctrl);
assert_eq!(kb.code, KeyCode::Char('t'));
let kb = parse_keybind("ctrl+w").unwrap();
assert!(kb.ctrl);
assert_eq!(kb.code, KeyCode::Char('w'));
let kb = parse_keybind("CTRL+q").unwrap();
assert!(kb.ctrl);
assert_eq!(kb.code, KeyCode::Char('q'));
}
#[test]
fn parse_plain_key() {
let kb = parse_keybind("Tab").unwrap();
assert!(!kb.ctrl);
assert_eq!(kb.code, KeyCode::Tab);
let kb = parse_keybind("F5").unwrap();
assert!(!kb.ctrl);
}
#[test]
fn update_config_defaults_to_enabled() {
let cfg = UpdateConfig::default();
assert!(cfg.check_on_startup);
assert!(cfg.skip_version.is_empty());
}
#[test]
fn config_without_update_section_parses() {
let cfg: AppConfig = toml::from_str("[ui]\ntheme = \"nord\"\n").unwrap();
assert_eq!(cfg.ui.theme, "nord");
assert!(cfg.update.check_on_startup);
}
#[test]
fn update_config_round_trips_through_toml() {
let mut cfg = AppConfig::default();
cfg.update.check_on_startup = false;
cfg.update.skip_version = "1.2.3".to_string();
let serialized = toml::to_string_pretty(&cfg).unwrap();
let parsed: AppConfig = toml::from_str(&serialized).unwrap();
assert!(!parsed.update.check_on_startup);
assert_eq!(parsed.update.skip_version, "1.2.3");
}
}