Skip to main content

omnyssh_core/config/
app_config.rs

1use anyhow::Context;
2use serde::{Deserialize, Serialize};
3
4/// Main application configuration, loaded from
5/// `~/.config/omnyssh/config.toml`.
6#[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/// General / runtime settings.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct GeneralConfig {
20    /// Seconds between automatic metric refreshes.
21    pub refresh_interval: u64,
22    pub default_shell: String,
23    /// Path to the system SSH binary.
24    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/// Visual / theme settings.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(default)]
42pub struct UiConfig {
43    /// One of: `default`, `dracula`, `nord`, `gruvbox`.
44    pub theme: String,
45    // TODO(future-stage): these fields are parsed from user config but not yet
46    // wired up to the renderer.  They are kept in the struct so existing config
47    // files are accepted without error; the renderer will consume them once the
48    // corresponding UI features land.
49    pub show_ip: bool,
50    pub show_uptime: bool,
51    /// One of: `grid`, `list`.
52    pub card_layout: String,
53    /// One of: `rounded`, `plain`, `double`.
54    pub border_style: String,
55}
56
57impl UiConfig {
58    /// Returns the list of all available built-in theme names.
59    ///
60    /// These names correspond to the built-in themes of the TUI frontend.
61    pub fn available_themes() -> &'static [&'static str] {
62        &["default", "dracula", "nord", "gruvbox"]
63    }
64
65    /// Checks if the given theme name is valid.
66    ///
67    /// # Examples
68    /// ```
69    /// # use omnyssh_core::config::app_config::UiConfig;
70    /// assert!(UiConfig::is_valid_theme("dracula"));
71    /// assert!(!UiConfig::is_valid_theme("unknown"));
72    /// ```
73    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/// Keyboard shortcut overrides (all values are key name strings).
91///
92/// Supports plain key names (`"Tab"`, `"q"`, `"F1"`) and `"Ctrl+<char>"` format
93/// (e.g. `"Ctrl+T"`, `"Ctrl+W"`) for modifiers.
94#[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    /// Key to cycle to the next app screen (dashboard → files → snippets →
103    /// terminal).  Also used to switch panels in File Manager.
104    /// Default: `"Tab"`.
105    pub next_screen: String,
106    /// Key to cycle terminal tabs / split panes.
107    /// Default: `"Ctrl+N"`.
108    pub next_tab: String,
109}
110
111/// Auto SSH Key Setup configuration.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct AutoKeySetupConfig {
115    /// Enable the auto key setup feature.
116    pub enabled: bool,
117    /// Show suggestion banner when password authentication is detected.
118    pub suggest_on_password_auth: bool,
119    /// Automatically disable password authentication after key setup (requires sudo).
120    pub disable_password_auth: bool,
121    /// SSH key type to generate (ed25519 | rsa-4096).
122    pub key_type: String,
123    /// Directory where SSH keys are stored (default: ~/.ssh).
124    pub key_directory: String,
125    /// Always create a backup of sshd_config before modification.
126    pub backup_sshd_config: bool,
127    /// Ask for confirmation before disabling password authentication.
128    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/// Update checker configuration.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(default)]
148pub struct UpdateConfig {
149    /// Check GitHub Releases for a newer version on startup.
150    pub check_on_startup: bool,
151    /// A version the user chose to skip; it is never offered again.
152    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
178// ---------------------------------------------------------------------------
179// Config file loading
180// ---------------------------------------------------------------------------
181
182/// Loads the application config from `path`, or from the default location
183/// (`~/.config/omnyssh/config.toml`) when `path` is `None`.
184///
185/// A missing config file is silently ignored and [`AppConfig::default`] is
186/// returned.  Parse errors are propagated so the user sees them at startup.
187///
188/// # Errors
189/// Returns an error only if the file exists but cannot be read or parsed.
190pub 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
214/// Loads the on-disk config (or a default), applies `mutator`, and writes it
215/// back. Reading fresh from disk avoids clobbering unrelated edits.
216///
217/// # Errors
218/// Returns an error if the config file cannot be read, parsed, or written.
219fn 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
256/// Saves the theme selection to the config file's `[ui]` section.
257///
258/// # Errors
259/// Returns an error if the config file cannot be written or parsed.
260pub fn save_theme_to_config(theme_name: &str) -> anyhow::Result<()> {
261    persist_config(|config| config.ui.theme = theme_name.to_string())
262}
263
264/// Saves the update-checker preferences to the config file's `[update]`
265/// section.
266///
267/// # Errors
268/// Returns an error if the config file cannot be written or parsed.
269pub 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    /// A config file written by an older release (no `[update]` section)
286    /// must still parse, falling back to the default update settings.
287    #[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}