184 lines
5.9 KiB
Rust
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");
|
|
}
|
|
}
|