Initial commit
This commit is contained in:
279
src-tauri/src/config/mod.rs
Normal file
279
src-tauri/src/config/mod.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
//! 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::ProjectConfig;
|
||||
|
||||
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 (fractions of the window width, 0..1).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LayoutConfig {
|
||||
pub left_pane_fraction: f32,
|
||||
pub right_pane_fraction: f32,
|
||||
}
|
||||
|
||||
impl Default for LayoutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
left_pane_fraction: 0.20,
|
||||
right_pane_fraction: 0.35,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Reference IDs of tabs that should be restored on launch.
|
||||
pub open_tabs: Vec<String>,
|
||||
}
|
||||
|
||||
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::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,
|
||||
};
|
||||
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!["tab-a".to_string(), "tab-b".to_string()],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let merged = MergedConfig::merge(&global, Some(&project));
|
||||
assert_eq!(merged.open_tabs, ["tab-a", "tab-b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_layout_always_from_global() {
|
||||
let global = GlobalConfig {
|
||||
layout: LayoutConfig {
|
||||
left_pane_fraction: 0.30,
|
||||
right_pane_fraction: 0.40,
|
||||
},
|
||||
..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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user