Files
brittle/src-tauri/src/config/global.rs
2026-03-25 09:32:02 +01:00

184 lines
5.9 KiB
Rust

//! 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");
}
}