Files
brittle/src-tauri/src/config/mod.rs

305 lines
11 KiB
Rust

//! 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<Theme>,
pub font_size: Option<u32>,
}
/// 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<String, String>);
// ── 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<PdfTabEntry>,
}
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);
}
}