//! Configuration types for Brittle. //! //! Two levels of config exist: //! - [`GlobalConfig`] — stored at `~/.config/brittle/config.toml`; applies to all projects. //! - [`ProjectConfig`] — stored at `{repo}/.brittle/config.toml`; overrides globals per project. //! //! Use [`MergedConfig::merge`] to produce the effective config the app should use. mod global; mod project; pub use global::GlobalConfig; pub use project::{PdfTabEntry, PdfTabState, ProjectConfig, SessionConfig}; use std::collections::HashMap; use serde::{Deserialize, Serialize}; // ── Shared types ────────────────────────────────────────────────────────────── /// Application colour theme. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum Theme { #[default] Dark, Light, } /// Appearance settings (font size, theme). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct AppearanceConfig { pub theme: Theme, pub font_size: u32, } impl Default for AppearanceConfig { fn default() -> Self { Self { theme: Theme::Dark, font_size: 14, } } } /// Partial appearance override from a project config. /// Only `Some` fields replace the global value. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct AppearanceOverride { pub theme: Option, pub font_size: Option, } /// Pane layout proportions and constraints. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct LayoutConfig { /// Initial fraction of the window width for the left pane (0..1). pub left_pane_fraction: f32, /// Initial fraction of the window width for the right pane (0..1). pub right_pane_fraction: f32, /// Minimum width of the left pane in pixels. pub left_pane_min: i32, /// Maximum width of the left pane in pixels. pub left_pane_max: i32, /// Minimum width of the right pane in pixels. pub right_pane_min: i32, /// Maximum width of the right pane in pixels. pub right_pane_max: i32, /// Minimum width of the center pane in pixels. pub center_pane_min: i32, } impl Default for LayoutConfig { fn default() -> Self { Self { left_pane_fraction: 0.20, right_pane_fraction: 0.35, left_pane_min: 120, left_pane_max: 600, right_pane_min: 150, right_pane_max: 600, center_pane_min: 200, } } } /// Keybinding overrides — maps action name to key combo string. /// /// Only actions the user wants to rebind need an entry here; everything else /// falls back to the built-in defaults defined in `keymap`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct KeybindingsConfig(pub HashMap); // ── Merged config ───────────────────────────────────────────────────────────── /// The effective config the application uses at runtime, produced by merging /// global defaults with per-project overrides. #[derive(Debug, Clone)] #[allow(dead_code)] // consumed in Phase 3 when AppState is wired up pub struct MergedConfig { pub appearance: AppearanceConfig, pub layout: LayoutConfig, pub keybindings: KeybindingsConfig, /// Tabs that should be restored on launch. pub open_tabs: Vec, } impl MergedConfig { #[allow(dead_code)] // consumed in Phase 3 when AppState is wired up /// Merge `global` with an optional `project` override. /// /// Project values take precedence for appearance; layout and keybindings /// are always taken from the global config (per-project overrides for those /// are intentionally not supported — it would be confusing). pub fn merge(global: &GlobalConfig, project: Option<&ProjectConfig>) -> Self { let appearance = match project.and_then(|p| p.appearance.as_ref()) { Some(ov) => AppearanceConfig { theme: ov .theme .clone() .unwrap_or_else(|| global.appearance.theme.clone()), font_size: ov.font_size.unwrap_or(global.appearance.font_size), }, None => global.appearance.clone(), }; Self { appearance, layout: global.layout.clone(), keybindings: global.keybindings.clone(), open_tabs: project .map(|p| p.session.open_tabs.clone()) .unwrap_or_default(), } } } // ── Error type ──────────────────────────────────────────────────────────────── #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("no config directory found on this platform")] NoConfigDir, #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("TOML parse error: {0}")] Parse(#[from] toml::de::Error), #[error("TOML serialize error: {0}")] Serialize(#[from] toml::ser::Error), } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::config::project::{PdfTabEntry, SessionConfig}; // ── AppearanceConfig ────────────────────────────────────────────────────── #[test] fn appearance_defaults_are_dark_14pt() { let a = AppearanceConfig::default(); assert_eq!(a.theme, Theme::Dark); assert_eq!(a.font_size, 14); } #[test] fn appearance_round_trips() { let original = AppearanceConfig { theme: Theme::Light, font_size: 16, }; let s = toml::to_string_pretty(&original).unwrap(); let parsed: AppearanceConfig = toml::from_str(&s).unwrap(); assert_eq!(parsed, original); } #[test] fn appearance_missing_fields_use_defaults() { // Only theme specified; font_size should fall back to 14. let parsed: AppearanceConfig = toml::from_str("theme = \"light\"").unwrap(); assert_eq!(parsed.theme, Theme::Light); assert_eq!(parsed.font_size, 14); } // ── LayoutConfig ───────────────────────────────────────────────────────── #[test] fn layout_defaults() { let l = LayoutConfig::default(); assert!((l.left_pane_fraction - 0.20).abs() < f32::EPSILON); assert!((l.right_pane_fraction - 0.35).abs() < f32::EPSILON); } #[test] fn layout_round_trips() { let original = LayoutConfig { left_pane_fraction: 0.25, right_pane_fraction: 0.40, ..LayoutConfig::default() }; let s = toml::to_string_pretty(&original).unwrap(); let parsed: LayoutConfig = toml::from_str(&s).unwrap(); assert_eq!(parsed, original); } // ── MergedConfig ───────────────────────────────────────────────────────── #[test] fn merge_without_project_uses_globals() { let global = GlobalConfig { appearance: AppearanceConfig { theme: Theme::Light, font_size: 16, }, ..Default::default() }; let merged = MergedConfig::merge(&global, None); assert_eq!(merged.appearance.theme, Theme::Light); assert_eq!(merged.appearance.font_size, 16); assert!(merged.open_tabs.is_empty()); } #[test] fn merge_project_overrides_theme() { let global = GlobalConfig { appearance: AppearanceConfig { theme: Theme::Dark, font_size: 14, }, ..Default::default() }; let project = ProjectConfig { appearance: Some(AppearanceOverride { theme: Some(Theme::Light), font_size: None, }), ..Default::default() }; let merged = MergedConfig::merge(&global, Some(&project)); assert_eq!(merged.appearance.theme, Theme::Light); // font_size not overridden — inherits from global assert_eq!(merged.appearance.font_size, 14); } #[test] fn merge_project_overrides_font_size_only() { let global = GlobalConfig { appearance: AppearanceConfig { theme: Theme::Dark, font_size: 14, }, ..Default::default() }; let project = ProjectConfig { appearance: Some(AppearanceOverride { theme: None, font_size: Some(18), }), ..Default::default() }; let merged = MergedConfig::merge(&global, Some(&project)); assert_eq!(merged.appearance.theme, Theme::Dark); assert_eq!(merged.appearance.font_size, 18); } #[test] fn merge_project_open_tabs_are_included() { let global = GlobalConfig::default(); let project = ProjectConfig { session: SessionConfig { open_tabs: vec![ PdfTabEntry { ref_id: "tab-a".to_string(), title: "a2024".to_string() }, PdfTabEntry { ref_id: "tab-b".to_string(), title: "b2024".to_string() }, ], ..Default::default() }, ..Default::default() }; let merged = MergedConfig::merge(&global, Some(&project)); assert_eq!(merged.open_tabs.len(), 2); assert_eq!(merged.open_tabs[0].ref_id, "tab-a"); assert_eq!(merged.open_tabs[1].ref_id, "tab-b"); } #[test] fn merge_layout_always_from_global() { let global = GlobalConfig { layout: LayoutConfig { left_pane_fraction: 0.30, right_pane_fraction: 0.40, ..LayoutConfig::default() }, ..Default::default() }; // Project config has no layout override capability. let merged = MergedConfig::merge(&global, None); assert!((merged.layout.left_pane_fraction - 0.30).abs() < f32::EPSILON); } }