use ratatui::style::Color;
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct KizuConfig {
pub keys: KeyConfig,
pub colors: ColorConfig,
pub timing: TimingConfig,
pub editor: EditorConfig,
pub attach: AttachConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct KeyConfig {
pub ask: char,
pub reject: char,
pub comment: char,
pub revert: char,
pub editor: char,
pub seen: char,
pub follow: char,
pub search: char,
pub search_next: char,
pub search_prev: char,
pub picker: char,
pub reset_baseline: char,
pub cursor_placement: char,
pub wrap_toggle: char,
pub undo: char,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ColorConfig {
pub bg_added: [u8; 3],
pub bg_deleted: [u8; 3],
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TimingConfig {
pub debounce_worktree_ms: u64,
pub debounce_git_dir_ms: u64,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct EditorConfig {
pub command: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct AttachConfig {
pub terminal: String,
}
impl Default for KeyConfig {
fn default() -> Self {
Self {
ask: 'a',
reject: 'r',
comment: 'c',
revert: 'x',
editor: 'e',
seen: ' ',
follow: 'f',
search: '/',
search_next: 'n',
search_prev: 'N',
picker: 's',
reset_baseline: 'R',
cursor_placement: 'z',
wrap_toggle: 'w',
undo: 'u',
}
}
}
impl KeyConfig {
fn bindings(&self) -> [(&'static str, char); 15] {
[
("ask", self.ask),
("reject", self.reject),
("comment", self.comment),
("revert", self.revert),
("editor", self.editor),
("seen", self.seen),
("follow", self.follow),
("search", self.search),
("search_next", self.search_next),
("search_prev", self.search_prev),
("picker", self.picker),
("reset_baseline", self.reset_baseline),
("cursor_placement", self.cursor_placement),
("wrap_toggle", self.wrap_toggle),
("undo", self.undo),
]
}
pub fn conflicts(&self) -> Vec<(char, Vec<&'static str>)> {
use std::collections::BTreeMap;
let mut by_char: BTreeMap<char, Vec<&'static str>> = BTreeMap::new();
for (name, ch) in self.bindings() {
by_char.entry(ch).or_default().push(name);
}
by_char
.into_iter()
.filter(|(_, names)| names.len() > 1)
.collect()
}
}
impl Default for ColorConfig {
fn default() -> Self {
Self {
bg_added: [10, 50, 10],
bg_deleted: [60, 10, 10],
}
}
}
impl Default for TimingConfig {
fn default() -> Self {
Self {
debounce_worktree_ms: 300,
debounce_git_dir_ms: 100,
}
}
}
impl ColorConfig {
pub fn bg_added_color(&self) -> Color {
Color::Rgb(self.bg_added[0], self.bg_added[1], self.bg_added[2])
}
pub fn bg_deleted_color(&self) -> Color {
Color::Rgb(self.bg_deleted[0], self.bg_deleted[1], self.bg_deleted[2])
}
}
pub fn load_config() -> KizuConfig {
let path = match crate::paths::config_file() {
Some(p) => p,
None => return KizuConfig::default(),
};
load_config_from(&path)
}
pub fn load_config_from(path: &Path) -> KizuConfig {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return KizuConfig::default(),
};
let config: KizuConfig = match toml::from_str(&content) {
Ok(config) => config,
Err(e) => {
eprintln!(
"kizu: warning: failed to parse config {}: {e}",
path.display()
);
return KizuConfig::default();
}
};
for (ch, actions) in config.keys.conflicts() {
let display = if ch == ' ' {
"<space>".to_string()
} else {
ch.to_string()
};
eprintln!(
"kizu: warning: config key {display:?} is bound to multiple actions: {}",
actions.join(", ")
);
}
config
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_config_conflicts_returns_empty_for_defaults() {
let config = KizuConfig::default();
assert!(
config.keys.conflicts().is_empty(),
"default key map must have no duplicates"
);
}
#[test]
fn key_config_conflicts_reports_duplicate_assignments() {
let mut config = KizuConfig::default();
config.keys.reject = 'a'; let conflicts = config.keys.conflicts();
assert_eq!(
conflicts.len(),
1,
"one group of conflicting actions expected, got: {conflicts:?}",
);
let (ch, names) = &conflicts[0];
assert_eq!(*ch, 'a');
assert!(names.contains(&"ask"));
assert!(names.contains(&"reject"));
}
#[test]
fn key_config_conflicts_ignores_space_search_next_prev_defaults() {
let config = KizuConfig::default();
assert!(config.keys.conflicts().is_empty());
}
#[test]
fn default_config_has_correct_key_values() {
let config = KizuConfig::default();
assert_eq!(config.keys.ask, 'a');
assert_eq!(config.keys.reject, 'r');
assert_eq!(config.keys.comment, 'c');
assert_eq!(config.keys.revert, 'x');
assert_eq!(config.keys.editor, 'e');
assert_eq!(config.keys.seen, ' ');
assert_eq!(config.keys.follow, 'f');
assert_eq!(config.keys.search, '/');
assert_eq!(config.keys.picker, 's');
}
#[test]
fn default_config_has_correct_colors() {
let config = KizuConfig::default();
assert_eq!(config.colors.bg_added, [10, 50, 10]);
assert_eq!(config.colors.bg_deleted, [60, 10, 10]);
assert_eq!(config.colors.bg_added_color(), Color::Rgb(10, 50, 10));
assert_eq!(config.colors.bg_deleted_color(), Color::Rgb(60, 10, 10));
}
#[test]
fn default_config_has_correct_timing() {
let config = KizuConfig::default();
assert_eq!(config.timing.debounce_worktree_ms, 300);
assert_eq!(config.timing.debounce_git_dir_ms, 100);
}
#[test]
fn toml_partial_override_only_changes_specified_fields() {
let toml_str = r#"
[keys]
ask = "A"
[colors]
bg_added = [0, 80, 0]
"#;
let config: KizuConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.keys.ask, 'A');
assert_eq!(config.colors.bg_added, [0, 80, 0]);
assert_eq!(config.keys.reject, 'r');
assert_eq!(config.keys.comment, 'c');
assert_eq!(config.colors.bg_deleted, [60, 10, 10]);
assert_eq!(config.timing.debounce_worktree_ms, 300);
}
#[test]
fn toml_empty_string_parses_to_defaults() {
let config: KizuConfig = toml::from_str("").unwrap();
assert_eq!(config.keys.ask, 'a');
assert_eq!(config.colors.bg_added, [10, 50, 10]);
}
#[test]
fn load_config_from_nonexistent_file_returns_defaults() {
let config = load_config_from(Path::new("/nonexistent/kizu/config.toml"));
assert_eq!(config.keys.ask, 'a');
}
#[test]
fn load_config_from_invalid_toml_returns_defaults() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "this is not valid toml {{{").unwrap();
let config = load_config_from(tmp.path());
assert_eq!(config.keys.ask, 'a');
}
#[test]
fn color_config_produces_correct_rgb() {
let config = ColorConfig {
bg_added: [20, 60, 20],
..Default::default()
};
assert_eq!(config.bg_added_color(), Color::Rgb(20, 60, 20));
}
}