Initial commit
This commit is contained in:
183
src-tauri/src/config/global.rs
Normal file
183
src-tauri/src/config/global.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! 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<PathBuf>,
|
||||
pub last_opened: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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, ConfigError> {
|
||||
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<Self, ConfigError> {
|
||||
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<PathBuf, ConfigError> {
|
||||
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<S: Serializer>(kc: &KeybindingsConfig, s: S) -> Result<S::Ok, S::Error> {
|
||||
kc.0.serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<KeybindingsConfig, D::Error> {
|
||||
HashMap::<String, String>::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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user