#[cfg(feature = "tui")]
use ratatui::style::Color;
use crate::agent::{AgentKind, AgentModel, Effort};
use crate::cx::Env;
#[cfg(feature = "tui")]
use crate::keys::{KeyAction, KeyChord, Keymap};
use crate::model::Column;
use crate::output::color::{ColorChoice, resolve_color};
use crate::template::DEFAULT_TEMPLATE;
#[cfg(feature = "tui")]
use crate::tui::theme::{Palette, ThemePreset};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SubmoduleInit {
#[default]
Prompt,
Never,
Always,
}
impl SubmoduleInit {
pub fn parse(value: &str) -> Option<SubmoduleInit> {
match value {
"prompt" => Some(SubmoduleInit::Prompt),
"never" => Some(SubmoduleInit::Never),
"always" => Some(SubmoduleInit::Always),
_ => None,
}
}
pub fn identifier(self) -> &'static str {
match self {
SubmoduleInit::Prompt => "prompt",
SubmoduleInit::Never => "never",
SubmoduleInit::Always => "always",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SubmoduleSeed {
#[default]
Auto,
Never,
}
impl SubmoduleSeed {
pub fn parse(value: &str) -> Option<SubmoduleSeed> {
match value {
"auto" => Some(SubmoduleSeed::Auto),
"never" => Some(SubmoduleSeed::Never),
_ => None,
}
}
pub fn is_enabled(self) -> bool {
matches!(self, SubmoduleSeed::Auto)
}
pub fn identifier(self) -> &'static str {
match self {
SubmoduleSeed::Auto => "auto",
SubmoduleSeed::Never => "never",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum CreateReflink {
#[default]
Never,
Auto,
}
impl CreateReflink {
pub fn parse(value: &str) -> Option<CreateReflink> {
match value {
"auto" => Some(CreateReflink::Auto),
"never" => Some(CreateReflink::Never),
_ => None,
}
}
pub fn is_enabled(self) -> bool {
matches!(self, CreateReflink::Auto)
}
pub fn identifier(self) -> &'static str {
match self {
CreateReflink::Auto => "auto",
CreateReflink::Never => "never",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GenerationAgentConfig {
pub provider: AgentKind,
pub model: AgentModel,
pub effort: Effort,
}
impl Default for GenerationAgentConfig {
fn default() -> Self {
GenerationAgentConfig {
provider: AgentKind::Claude,
model: AgentModel::default(),
effort: Effort::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub path_template: String,
pub default_base: Option<String>,
pub copy: Vec<String>,
pub hooks_post_create: Option<String>,
pub hooks_pre_remove: Option<String>,
pub editor: Option<String>,
pub remove_delete_merged_branch: bool,
pub remove_untracked_blocks: bool,
pub pr_default_remote: String,
pub submodules_init: SubmoduleInit,
pub submodules_seed: SubmoduleSeed,
pub create_reflink: CreateReflink,
pub agent_generation: GenerationAgentConfig,
pub list_show_untracked: bool,
pub list_columns: Vec<Column>,
pub ui_nerd_fonts: bool,
pub ui_mouse: bool,
pub ui_color: ColorChoice,
#[cfg(feature = "tui")]
pub ui_theme: ThemePreset,
#[cfg(feature = "tui")]
pub theme_overrides: ThemeOverrides,
#[cfg(feature = "tui")]
pub keybinding_overrides: Vec<(KeyAction, KeyChord)>,
}
impl Default for Config {
fn default() -> Self {
Config {
path_template: DEFAULT_TEMPLATE.to_string(),
default_base: None,
copy: Vec::new(),
hooks_post_create: None,
hooks_pre_remove: None,
editor: None,
remove_delete_merged_branch: true,
remove_untracked_blocks: false,
pr_default_remote: "origin".to_string(),
submodules_init: SubmoduleInit::default(),
submodules_seed: SubmoduleSeed::default(),
create_reflink: CreateReflink::default(),
agent_generation: GenerationAgentConfig::default(),
list_show_untracked: true,
list_columns: Column::ALL.to_vec(),
ui_nerd_fonts: false,
ui_mouse: true,
ui_color: ColorChoice::Auto,
#[cfg(feature = "tui")]
ui_theme: ThemePreset::default(),
#[cfg(feature = "tui")]
theme_overrides: ThemeOverrides::default(),
#[cfg(feature = "tui")]
keybinding_overrides: Vec::new(),
}
}
}
impl Config {
pub fn apply(&mut self, layer: ConfigLayer) {
if let Some(v) = layer.path_template {
self.path_template = v;
}
if let Some(v) = layer.default_base {
self.default_base = Some(v);
}
if let Some(v) = layer.copy {
self.copy = v;
}
if let Some(v) = layer.editor {
self.editor = Some(v);
}
if let Some(v) = layer.hooks_post_create {
self.hooks_post_create = Some(v);
}
if let Some(v) = layer.hooks_pre_remove {
self.hooks_pre_remove = Some(v);
}
if let Some(v) = layer.remove_delete_merged_branch {
self.remove_delete_merged_branch = v;
}
if let Some(v) = layer.remove_untracked_blocks {
self.remove_untracked_blocks = v;
}
if let Some(v) = layer.pr_default_remote {
self.pr_default_remote = v;
}
if let Some(v) = layer.submodules_init {
self.submodules_init = v;
}
if let Some(v) = layer.submodules_seed {
self.submodules_seed = v;
}
if let Some(v) = layer.create_reflink {
self.create_reflink = v;
}
if let Some(v) = layer.agent_generation_provider {
self.agent_generation.provider = v;
}
if let Some(v) = layer.agent_generation_model {
self.agent_generation.model = v;
}
if let Some(v) = layer.agent_generation_effort {
self.agent_generation.effort = v;
}
if let Some(v) = layer.list_show_untracked {
self.list_show_untracked = v;
}
if let Some(v) = layer.list_columns {
self.list_columns = v;
}
if let Some(v) = layer.ui_nerd_fonts {
self.ui_nerd_fonts = v;
}
if let Some(v) = layer.ui_mouse {
self.ui_mouse = v;
}
if let Some(v) = layer.ui_color {
self.ui_color = v;
}
#[cfg(feature = "tui")]
{
if let Some(v) = layer.ui_theme {
self.ui_theme = v;
}
self.theme_overrides.merge(layer.theme_overrides);
self.keybinding_overrides.extend(layer.ui_keybindings);
}
}
#[cfg(feature = "tui")]
pub fn palette(&self) -> Palette {
let mut palette = self.ui_theme.palette();
self.theme_overrides.apply_to(&mut palette);
palette
}
#[cfg(feature = "tui")]
pub fn keymap(&self) -> Keymap {
let mut keymap = Keymap::defaults();
for (action, chord) in &self.keybinding_overrides {
keymap.rebind(*action, *chord);
}
keymap
}
pub fn color_enabled(&self, flag: Option<ColorChoice>, env: &Env, stdout_is_tty: bool) -> bool {
resolve_color(
flag,
env.is_set_nonempty("NO_COLOR"),
Some(self.ui_color),
stdout_is_tty,
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConfigLayer {
pub path_template: Option<String>,
pub default_base: Option<String>,
pub copy: Option<Vec<String>>,
pub editor: Option<String>,
pub hooks_post_create: Option<String>,
pub hooks_pre_remove: Option<String>,
pub remove_delete_merged_branch: Option<bool>,
pub remove_untracked_blocks: Option<bool>,
pub pr_default_remote: Option<String>,
pub submodules_init: Option<SubmoduleInit>,
pub submodules_seed: Option<SubmoduleSeed>,
pub create_reflink: Option<CreateReflink>,
pub agent_generation_provider: Option<AgentKind>,
pub agent_generation_model: Option<AgentModel>,
pub agent_generation_effort: Option<Effort>,
pub list_show_untracked: Option<bool>,
pub list_columns: Option<Vec<Column>>,
pub ui_nerd_fonts: Option<bool>,
pub ui_mouse: Option<bool>,
pub ui_color: Option<ColorChoice>,
#[cfg(feature = "tui")]
pub ui_theme: Option<ThemePreset>,
#[cfg(feature = "tui")]
pub theme_overrides: ThemeOverrides,
#[cfg(feature = "tui")]
pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
}
#[cfg(feature = "tui")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ThemeOverrides {
pub accent: Option<Color>,
pub green: Option<Color>,
pub red: Option<Color>,
pub yellow: Option<Color>,
pub orange: Option<Color>,
pub cyan: Option<Color>,
pub magenta: Option<Color>,
pub gray: Option<Color>,
pub selection_bg: Option<Color>,
pub chip_fg: Option<Color>,
}
#[cfg(feature = "tui")]
impl ThemeOverrides {
pub fn merge(&mut self, other: ThemeOverrides) {
self.accent = other.accent.or(self.accent);
self.green = other.green.or(self.green);
self.red = other.red.or(self.red);
self.yellow = other.yellow.or(self.yellow);
self.orange = other.orange.or(self.orange);
self.cyan = other.cyan.or(self.cyan);
self.magenta = other.magenta.or(self.magenta);
self.gray = other.gray.or(self.gray);
self.selection_bg = other.selection_bg.or(self.selection_bg);
self.chip_fg = other.chip_fg.or(self.chip_fg);
}
fn apply_to(&self, palette: &mut Palette) {
if let Some(c) = self.accent {
palette.accent = c;
}
if let Some(c) = self.green {
palette.green = c;
}
if let Some(c) = self.red {
palette.red = c;
}
if let Some(c) = self.yellow {
palette.yellow = c;
}
if let Some(c) = self.orange {
palette.orange = c;
}
if let Some(c) = self.cyan {
palette.cyan = c;
}
if let Some(c) = self.magenta {
palette.magenta = c;
}
if let Some(c) = self.gray {
palette.gray = c;
}
if let Some(c) = self.selection_bg {
palette.selection_bg = c;
}
if let Some(c) = self.chip_fg {
palette.chip_fg = c;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "tui")]
use crossterm::event::KeyCode;
#[test]
fn defaults_match_spec() {
let c = Config::default();
assert_eq!(c.path_template, DEFAULT_TEMPLATE);
assert!(c.default_base.is_none());
assert!(c.copy.is_empty());
assert!(c.remove_delete_merged_branch);
assert!(!c.remove_untracked_blocks);
assert_eq!(c.pr_default_remote, "origin");
assert_eq!(c.submodules_init, SubmoduleInit::Prompt);
assert_eq!(c.agent_generation.provider, AgentKind::Claude);
assert_eq!(c.agent_generation.model, AgentModel::Sonnet);
assert_eq!(c.agent_generation.effort, Effort::Medium);
assert!(c.list_show_untracked);
assert_eq!(c.list_columns, Column::ALL.to_vec());
assert!(!c.ui_nerd_fonts);
assert!(c.ui_mouse);
assert_eq!(c.ui_color, ColorChoice::Auto);
}
#[test]
fn scalars_replace_on_apply() {
let mut c = Config::default();
c.apply(ConfigLayer {
pr_default_remote: Some("upstream".into()),
ui_mouse: Some(false),
..Default::default()
});
assert_eq!(c.pr_default_remote, "upstream");
assert!(!c.ui_mouse);
assert!(c.list_show_untracked);
}
#[test]
fn arrays_replace_wholesale() {
let mut c = Config::default();
c.apply(ConfigLayer {
copy: Some(vec![".env".into()]),
list_columns: Some(vec![Column::Branch, Column::Pr]),
..Default::default()
});
assert_eq!(c.copy, vec![".env".to_string()]);
assert_eq!(c.list_columns, vec![Column::Branch, Column::Pr]);
c.apply(ConfigLayer {
copy: Some(vec![".envrc".into()]),
..Default::default()
});
assert_eq!(c.copy, vec![".envrc".to_string()]);
}
#[test]
fn apply_sets_every_scalar_and_optional_field() {
let mut c = Config::default();
c.apply(ConfigLayer {
path_template: Some("{home}/{branch_slug}".into()),
default_base: Some("trunk".into()),
editor: Some("hx".into()),
hooks_post_create: Some("setup".into()),
hooks_pre_remove: Some("teardown".into()),
remove_delete_merged_branch: Some(false),
remove_untracked_blocks: Some(true),
submodules_init: Some(SubmoduleInit::Always),
agent_generation_model: Some(AgentModel::Haiku),
agent_generation_effort: Some(Effort::Low),
list_show_untracked: Some(false),
ui_nerd_fonts: Some(true),
ui_color: Some(ColorChoice::Never),
..Default::default()
});
assert_eq!(c.path_template, "{home}/{branch_slug}");
assert_eq!(c.default_base.as_deref(), Some("trunk"));
assert_eq!(c.editor.as_deref(), Some("hx"));
assert_eq!(c.hooks_post_create.as_deref(), Some("setup"));
assert_eq!(c.hooks_pre_remove.as_deref(), Some("teardown"));
assert!(!c.remove_delete_merged_branch);
assert!(c.remove_untracked_blocks);
assert_eq!(c.submodules_init, SubmoduleInit::Always);
assert_eq!(c.agent_generation.model, AgentModel::Haiku);
assert_eq!(c.agent_generation.effort, Effort::Low);
assert!(!c.list_show_untracked);
assert!(c.ui_nerd_fonts);
assert_eq!(c.ui_color, ColorChoice::Never);
}
#[test]
fn color_enabled_follows_precedence() {
use crate::output::color::ColorChoice;
let mut c = Config::default();
let no_env = Env::from_map(std::collections::HashMap::new());
assert!(c.color_enabled(None, &no_env, true));
assert!(!c.color_enabled(None, &no_env, false));
c.ui_color = ColorChoice::Never;
assert!(!c.color_enabled(None, &no_env, true));
assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
c.ui_color = ColorChoice::Always;
let no_color = Env::from_map(
[("NO_COLOR".to_string(), "1".to_string())]
.into_iter()
.collect(),
);
assert!(!c.color_enabled(None, &no_color, true));
}
#[cfg(feature = "tui")]
#[test]
fn keybindings_deep_merge_per_action() {
let mut c = Config::default();
c.apply(ConfigLayer {
ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
..Default::default()
});
c.apply(ConfigLayer {
ui_keybindings: vec![
(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('e'))),
(KeyAction::Quit, KeyChord::key(KeyCode::Char('x'))),
],
..Default::default()
});
let km = c.keymap();
assert_eq!(
km.action_for(KeyChord::key(KeyCode::Char('e'))),
Some(KeyAction::NavigateUp)
);
assert_eq!(km.action_for(KeyChord::key(KeyCode::Char('w'))), None);
assert_eq!(
km.action_for(KeyChord::key(KeyCode::Char('x'))),
Some(KeyAction::Quit)
);
assert_eq!(
km.action_for(KeyChord::key(KeyCode::Char('n'))),
Some(KeyAction::New)
);
}
#[cfg(feature = "tui")]
#[test]
fn theme_defaults_to_one_dark() {
let c = Config::default();
assert_eq!(c.ui_theme, ThemePreset::OneDark);
assert_eq!(c.theme_overrides, ThemeOverrides::default());
assert_eq!(c.palette(), Palette::one_dark());
}
#[cfg(feature = "tui")]
#[test]
fn theme_preset_and_overrides_apply_and_merge() {
let mut c = Config::default();
c.apply(ConfigLayer {
ui_theme: Some(ThemePreset::Solarized),
theme_overrides: ThemeOverrides {
accent: Some(Color::Rgb(1, 1, 1)),
..Default::default()
},
..Default::default()
});
c.apply(ConfigLayer {
theme_overrides: ThemeOverrides {
red: Some(Color::Rgb(2, 2, 2)),
..Default::default()
},
..Default::default()
});
assert_eq!(c.ui_theme, ThemePreset::Solarized);
let p = c.palette();
assert_eq!(p.accent, Color::Rgb(1, 1, 1));
assert_eq!(p.red, Color::Rgb(2, 2, 2));
assert_eq!(p.green, Palette::solarized().green);
}
#[cfg(feature = "tui")]
#[test]
fn later_theme_override_wins_for_same_slot() {
let mut o = ThemeOverrides {
accent: Some(Color::Rgb(1, 1, 1)),
..Default::default()
};
o.merge(ThemeOverrides {
accent: Some(Color::Rgb(9, 9, 9)),
..Default::default()
});
assert_eq!(o.accent, Some(Color::Rgb(9, 9, 9)));
}
}