use anyhow::Context;
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 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 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 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("/"),
dashboard: String::from("F1"),
file_manager: String::from("F2"),
snippets: String::from("F3"),
next_screen: String::from("Tab"),
next_tab: String::from("Ctrl+N"),
}
}
}
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()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
let _ = std::fs::set_permissions(&config_path, perms);
}
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 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");
}
}