//! Global (user-wide) configuration. use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use super::{AppearanceConfig, ConfigError, KeybindingsConfig, LayoutConfig}; /// Record of recently opened repositories, stored in the global config. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct ProjectsState { pub recent: Vec, pub last_opened: Option, } /// User-wide configuration, stored at `~/.config/brittle/config.toml`. /// /// All fields are optional in the file; missing fields fall back to their /// `Default` implementations. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct GlobalConfig { pub appearance: AppearanceConfig, pub layout: LayoutConfig, pub projects: ProjectsState, /// Flat map of action name → key combo string for user-defined overrides. #[serde(with = "keybindings_map")] pub keybindings: KeybindingsConfig, } impl GlobalConfig { /// Load from the standard platform config directory. /// /// Returns `Default` if the file does not yet exist. pub fn load() -> Result { Self::load_from(&global_config_path()?) } /// Load from an explicit path. /// /// Returns `Default` if the file does not exist. pub fn load_from(path: &Path) -> Result { if !path.exists() { return Ok(Self::default()); } let content = std::fs::read_to_string(path)?; Ok(toml::from_str(&content)?) } /// Save to the standard platform config directory, /// creating parent directories as needed. pub fn save(&self) -> Result<(), ConfigError> { self.save_to(&global_config_path()?) } /// Save to an explicit path, creating parent directories as needed. pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, toml::to_string_pretty(self)?)?; Ok(()) } } fn global_config_path() -> Result { dirs::config_dir() .ok_or(ConfigError::NoConfigDir) .map(|d| d.join("brittle").join("config.toml")) } /// Custom serde module so `KeybindingsConfig` round-trips as a flat TOML table. /// /// Stored in the file as: /// ```toml /// [keybindings] /// focus_left = "H" /// tab_next = "gt" /// ``` mod keybindings_map { use super::*; use serde::{Deserializer, Serializer}; pub fn serialize(kc: &KeybindingsConfig, s: S) -> Result { kc.0.serialize(s) } pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { HashMap::::deserialize(d).map(KeybindingsConfig) } } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::config::Theme; use tempfile::TempDir; #[test] fn global_config_defaults() { let cfg = GlobalConfig::default(); assert_eq!(cfg.appearance.theme, Theme::Dark); assert_eq!(cfg.appearance.font_size, 14); assert!(cfg.projects.recent.is_empty()); assert!(cfg.keybindings.0.is_empty()); } #[test] fn global_config_round_trips() { let mut cfg = GlobalConfig::default(); cfg.appearance.theme = Theme::Light; cfg.appearance.font_size = 16; cfg.keybindings .0 .insert("focus_left".to_string(), "C-h".to_string()); let s = toml::to_string_pretty(&cfg).unwrap(); let parsed: GlobalConfig = toml::from_str(&s).unwrap(); assert_eq!(parsed, cfg); } #[test] fn empty_toml_uses_all_defaults() { let cfg: GlobalConfig = toml::from_str("").unwrap(); assert_eq!(cfg.appearance.font_size, 14); assert!((cfg.layout.left_pane_fraction - 0.20).abs() < f32::EPSILON); } #[test] fn partial_toml_uses_defaults_for_missing_sections() { let toml = "[appearance]\ntheme = \"light\"\n"; let cfg: GlobalConfig = toml::from_str(toml).unwrap(); assert_eq!(cfg.appearance.theme, Theme::Light); // layout not specified — should be default assert!((cfg.layout.left_pane_fraction - 0.20).abs() < f32::EPSILON); } #[test] fn load_from_nonexistent_path_returns_default() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("does_not_exist.toml"); let cfg = GlobalConfig::load_from(&path).unwrap(); assert_eq!(cfg, GlobalConfig::default()); } #[test] fn save_to_and_load_from_round_trip() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("config.toml"); let mut original = GlobalConfig::default(); original.appearance.theme = Theme::Light; original.appearance.font_size = 18; original.save_to(&path).unwrap(); let loaded = GlobalConfig::load_from(&path).unwrap(); assert_eq!(loaded, original); } #[test] fn save_to_creates_parent_directories() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("nested").join("dirs").join("config.toml"); GlobalConfig::default().save_to(&path).unwrap(); assert!(path.exists()); } #[test] fn keybinding_overrides_round_trip() { let mut cfg = GlobalConfig::default(); cfg.keybindings .0 .insert("tab_next".to_string(), "C-Right".to_string()); let s = toml::to_string_pretty(&cfg).unwrap(); let parsed: GlobalConfig = toml::from_str(&s).unwrap(); assert_eq!(parsed.keybindings.0["tab_next"], "C-Right"); } }