1use anyhow::Context;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7#[serde(default)]
8pub struct AppConfig {
9 pub general: GeneralConfig,
10 pub ui: UiConfig,
11 pub keybindings: KeybindingsConfig,
12 pub auto_key_setup: AutoKeySetupConfig,
13 pub update: UpdateConfig,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct GeneralConfig {
20 pub refresh_interval: u64,
22 pub default_shell: String,
23 pub ssh_command: String,
25 pub max_concurrent_connections: usize,
26}
27
28impl Default for GeneralConfig {
29 fn default() -> Self {
30 Self {
31 refresh_interval: 30,
32 default_shell: String::from("/bin/bash"),
33 ssh_command: String::from("ssh"),
34 max_concurrent_connections: 10,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(default)]
42pub struct UiConfig {
43 pub theme: String,
45 pub show_ip: bool,
50 pub show_uptime: bool,
51 pub card_layout: String,
53 pub border_style: String,
55}
56
57impl UiConfig {
58 pub fn available_themes() -> &'static [&'static str] {
62 &["default", "dracula", "nord", "gruvbox"]
63 }
64
65 pub fn is_valid_theme(name: &str) -> bool {
74 Self::available_themes().contains(&name)
75 }
76}
77
78impl Default for UiConfig {
79 fn default() -> Self {
80 Self {
81 theme: String::from("default"),
82 show_ip: true,
83 show_uptime: true,
84 card_layout: String::from("grid"),
85 border_style: String::from("rounded"),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(default)]
96pub struct KeybindingsConfig {
97 pub quit: String,
98 pub search: String,
99 pub dashboard: String,
100 pub file_manager: String,
101 pub snippets: String,
102 pub next_screen: String,
106 pub next_tab: String,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct AutoKeySetupConfig {
115 pub enabled: bool,
117 pub suggest_on_password_auth: bool,
119 pub disable_password_auth: bool,
121 pub key_type: String,
123 pub key_directory: String,
125 pub backup_sshd_config: bool,
127 pub confirm_before_disable: bool,
129}
130
131impl Default for AutoKeySetupConfig {
132 fn default() -> Self {
133 Self {
134 enabled: true,
135 suggest_on_password_auth: true,
136 disable_password_auth: true,
137 key_type: String::from("ed25519"),
138 key_directory: String::from("~/.ssh"),
139 backup_sshd_config: true,
140 confirm_before_disable: true,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(default)]
148pub struct UpdateConfig {
149 pub check_on_startup: bool,
151 pub skip_version: String,
153}
154
155impl Default for UpdateConfig {
156 fn default() -> Self {
157 Self {
158 check_on_startup: true,
159 skip_version: String::new(),
160 }
161 }
162}
163
164impl Default for KeybindingsConfig {
165 fn default() -> Self {
166 Self {
167 quit: String::from("q"),
168 search: String::from("/"),
169 dashboard: String::from("F1"),
170 file_manager: String::from("F2"),
171 snippets: String::from("F3"),
172 next_screen: String::from("Tab"),
173 next_tab: String::from("Ctrl+N"),
174 }
175 }
176}
177
178pub fn load_app_config(path: Option<&std::path::Path>) -> anyhow::Result<AppConfig> {
191 use crate::utils::platform;
192
193 let config_path = match path {
194 Some(p) => p.to_path_buf(),
195 None => match platform::app_config_path() {
196 Some(p) => p,
197 None => return Ok(AppConfig::default()),
198 },
199 };
200
201 if !config_path.exists() {
202 return Ok(AppConfig::default());
203 }
204
205 let content = std::fs::read_to_string(&config_path)
206 .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
207
208 let config: AppConfig = toml::from_str(&content)
209 .with_context(|| format!("Failed to parse config: {}", config_path.display()))?;
210
211 Ok(config)
212}
213
214fn persist_config<F: FnOnce(&mut AppConfig)>(mutator: F) -> anyhow::Result<()> {
220 use crate::utils::platform;
221
222 let config_path = match platform::app_config_path() {
223 Some(p) => p,
224 None => anyhow::bail!("Cannot determine config path for this platform"),
225 };
226
227 if let Some(parent) = config_path.parent() {
228 std::fs::create_dir_all(parent)
229 .with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
230 }
231
232 let mut config = if config_path.exists() {
233 let content = std::fs::read_to_string(&config_path)
234 .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
235 toml::from_str::<AppConfig>(&content)
236 .with_context(|| format!("Failed to parse config: {}", config_path.display()))?
237 } else {
238 AppConfig::default()
239 };
240
241 mutator(&mut config);
242
243 let content = toml::to_string_pretty(&config).context("Failed to serialize config")?;
244 std::fs::write(&config_path, content)
245 .with_context(|| format!("Failed to write config: {}", config_path.display()))?;
246 #[cfg(unix)]
247 {
248 use std::os::unix::fs::PermissionsExt;
249 let perms = std::fs::Permissions::from_mode(0o600);
250 let _ = std::fs::set_permissions(&config_path, perms);
251 }
252
253 Ok(())
254}
255
256pub fn save_theme_to_config(theme_name: &str) -> anyhow::Result<()> {
261 persist_config(|config| config.ui.theme = theme_name.to_string())
262}
263
264pub fn save_update_config(update: &UpdateConfig) -> anyhow::Result<()> {
270 let update = update.clone();
271 persist_config(move |config| config.update = update)
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn update_config_defaults_to_enabled() {
280 let cfg = UpdateConfig::default();
281 assert!(cfg.check_on_startup);
282 assert!(cfg.skip_version.is_empty());
283 }
284
285 #[test]
288 fn config_without_update_section_parses() {
289 let cfg: AppConfig = toml::from_str("[ui]\ntheme = \"nord\"\n").unwrap();
290 assert_eq!(cfg.ui.theme, "nord");
291 assert!(cfg.update.check_on_startup);
292 }
293
294 #[test]
295 fn update_config_round_trips_through_toml() {
296 let mut cfg = AppConfig::default();
297 cfg.update.check_on_startup = false;
298 cfg.update.skip_version = "1.2.3".to_string();
299
300 let serialized = toml::to_string_pretty(&cfg).unwrap();
301 let parsed: AppConfig = toml::from_str(&serialized).unwrap();
302 assert!(!parsed.update.check_on_startup);
303 assert_eq!(parsed.update.skip_version, "1.2.3");
304 }
305}