1#[cfg(feature = "tui")]
5use ratatui::style::Color;
6
7use crate::agent::{AgentKind, AgentModel, Effort};
8use crate::cx::Env;
9#[cfg(feature = "tui")]
10use crate::keys::{KeyAction, KeyChord, Keymap};
11use crate::model::Column;
12use crate::output::color::{ColorChoice, resolve_color};
13use crate::template::DEFAULT_TEMPLATE;
14#[cfg(feature = "tui")]
15use crate::tui::theme::{Palette, ThemePreset};
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
24pub enum SubmoduleInit {
25 #[default]
29 Prompt,
30 Never,
32 Always,
35}
36
37impl SubmoduleInit {
38 pub fn parse(value: &str) -> Option<SubmoduleInit> {
40 match value {
41 "prompt" => Some(SubmoduleInit::Prompt),
42 "never" => Some(SubmoduleInit::Never),
43 "always" => Some(SubmoduleInit::Always),
44 _ => None,
45 }
46 }
47
48 pub fn identifier(self) -> &'static str {
50 match self {
51 SubmoduleInit::Prompt => "prompt",
52 SubmoduleInit::Never => "never",
53 SubmoduleInit::Always => "always",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub enum SubmoduleSeed {
72 #[default]
74 Auto,
75 Never,
77}
78
79impl SubmoduleSeed {
80 pub fn parse(value: &str) -> Option<SubmoduleSeed> {
82 match value {
83 "auto" => Some(SubmoduleSeed::Auto),
84 "never" => Some(SubmoduleSeed::Never),
85 _ => None,
86 }
87 }
88
89 pub fn is_enabled(self) -> bool {
91 matches!(self, SubmoduleSeed::Auto)
92 }
93
94 pub fn identifier(self) -> &'static str {
96 match self {
97 SubmoduleSeed::Auto => "auto",
98 SubmoduleSeed::Never => "never",
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
118pub enum CreateReflink {
119 #[default]
121 Never,
122 Auto,
124}
125
126impl CreateReflink {
127 pub fn parse(value: &str) -> Option<CreateReflink> {
129 match value {
130 "auto" => Some(CreateReflink::Auto),
131 "never" => Some(CreateReflink::Never),
132 _ => None,
133 }
134 }
135
136 pub fn is_enabled(self) -> bool {
138 matches!(self, CreateReflink::Auto)
139 }
140
141 pub fn identifier(self) -> &'static str {
143 match self {
144 CreateReflink::Auto => "auto",
145 CreateReflink::Never => "never",
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct GenerationAgentConfig {
159 pub provider: AgentKind,
161 pub model: AgentModel,
163 pub effort: Effort,
165}
166
167impl Default for GenerationAgentConfig {
168 fn default() -> Self {
169 GenerationAgentConfig {
172 provider: AgentKind::Claude,
173 model: AgentModel::default(),
174 effort: Effort::default(),
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Config {
182 pub path_template: String,
184 pub default_base: Option<String>,
187 pub copy: Vec<String>,
189 pub hooks_post_create: Option<String>,
191 pub hooks_pre_remove: Option<String>,
193 pub editor: Option<String>,
195 pub remove_delete_merged_branch: bool,
197 pub remove_untracked_blocks: bool,
200 pub pr_default_remote: String,
202 pub submodules_init: SubmoduleInit,
204 pub submodules_seed: SubmoduleSeed,
207 pub create_reflink: CreateReflink,
210 pub agent_generation: GenerationAgentConfig,
215 pub list_show_untracked: bool,
217 pub list_columns: Vec<Column>,
219 pub ui_nerd_fonts: bool,
221 pub ui_mouse: bool,
223 pub ui_color: ColorChoice,
225 #[cfg(feature = "tui")]
227 pub ui_theme: ThemePreset,
228 #[cfg(feature = "tui")]
230 pub theme_overrides: ThemeOverrides,
231 #[cfg(feature = "tui")]
233 pub keybinding_overrides: Vec<(KeyAction, KeyChord)>,
234}
235
236impl Default for Config {
237 fn default() -> Self {
238 Config {
239 path_template: DEFAULT_TEMPLATE.to_string(),
240 default_base: None,
241 copy: Vec::new(),
242 hooks_post_create: None,
243 hooks_pre_remove: None,
244 editor: None,
245 remove_delete_merged_branch: true,
246 remove_untracked_blocks: false,
247 pr_default_remote: "origin".to_string(),
248 submodules_init: SubmoduleInit::default(),
249 submodules_seed: SubmoduleSeed::default(),
250 create_reflink: CreateReflink::default(),
251 agent_generation: GenerationAgentConfig::default(),
252 list_show_untracked: true,
253 list_columns: Column::ALL.to_vec(),
254 ui_nerd_fonts: false,
255 ui_mouse: true,
256 ui_color: ColorChoice::Auto,
257 #[cfg(feature = "tui")]
258 ui_theme: ThemePreset::default(),
259 #[cfg(feature = "tui")]
260 theme_overrides: ThemeOverrides::default(),
261 #[cfg(feature = "tui")]
262 keybinding_overrides: Vec::new(),
263 }
264 }
265}
266
267impl Config {
268 pub fn apply(&mut self, layer: ConfigLayer) {
274 if let Some(v) = layer.path_template {
275 self.path_template = v;
276 }
277 if let Some(v) = layer.default_base {
278 self.default_base = Some(v);
279 }
280 if let Some(v) = layer.copy {
281 self.copy = v;
282 }
283 if let Some(v) = layer.editor {
284 self.editor = Some(v);
285 }
286 if let Some(v) = layer.hooks_post_create {
287 self.hooks_post_create = Some(v);
288 }
289 if let Some(v) = layer.hooks_pre_remove {
290 self.hooks_pre_remove = Some(v);
291 }
292 if let Some(v) = layer.remove_delete_merged_branch {
293 self.remove_delete_merged_branch = v;
294 }
295 if let Some(v) = layer.remove_untracked_blocks {
296 self.remove_untracked_blocks = v;
297 }
298 if let Some(v) = layer.pr_default_remote {
299 self.pr_default_remote = v;
300 }
301 if let Some(v) = layer.submodules_init {
302 self.submodules_init = v;
303 }
304 if let Some(v) = layer.submodules_seed {
305 self.submodules_seed = v;
306 }
307 if let Some(v) = layer.create_reflink {
308 self.create_reflink = v;
309 }
310 if let Some(v) = layer.agent_generation_provider {
311 self.agent_generation.provider = v;
312 }
313 if let Some(v) = layer.agent_generation_model {
314 self.agent_generation.model = v;
315 }
316 if let Some(v) = layer.agent_generation_effort {
317 self.agent_generation.effort = v;
318 }
319 if let Some(v) = layer.list_show_untracked {
320 self.list_show_untracked = v;
321 }
322 if let Some(v) = layer.list_columns {
323 self.list_columns = v;
324 }
325 if let Some(v) = layer.ui_nerd_fonts {
326 self.ui_nerd_fonts = v;
327 }
328 if let Some(v) = layer.ui_mouse {
329 self.ui_mouse = v;
330 }
331 if let Some(v) = layer.ui_color {
332 self.ui_color = v;
333 }
334 #[cfg(feature = "tui")]
335 {
336 if let Some(v) = layer.ui_theme {
337 self.ui_theme = v;
338 }
339 self.theme_overrides.merge(layer.theme_overrides);
340 self.keybinding_overrides.extend(layer.ui_keybindings);
341 }
342 }
343
344 #[cfg(feature = "tui")]
347 pub fn palette(&self) -> Palette {
348 let mut palette = self.ui_theme.palette();
349 self.theme_overrides.apply_to(&mut palette);
350 palette
351 }
352
353 #[cfg(feature = "tui")]
356 pub fn keymap(&self) -> Keymap {
357 let mut keymap = Keymap::defaults();
358 for (action, chord) in &self.keybinding_overrides {
359 keymap.rebind(*action, *chord);
360 }
361 keymap
362 }
363
364 pub fn color_enabled(&self, flag: Option<ColorChoice>, env: &Env, stdout_is_tty: bool) -> bool {
367 resolve_color(
368 flag,
369 env.is_set_nonempty("NO_COLOR"),
370 Some(self.ui_color),
371 stdout_is_tty,
372 )
373 }
374}
375
376#[derive(Debug, Clone, Default, PartialEq, Eq)]
379pub struct ConfigLayer {
380 pub path_template: Option<String>,
382 pub default_base: Option<String>,
384 pub copy: Option<Vec<String>>,
386 pub editor: Option<String>,
388 pub hooks_post_create: Option<String>,
390 pub hooks_pre_remove: Option<String>,
392 pub remove_delete_merged_branch: Option<bool>,
394 pub remove_untracked_blocks: Option<bool>,
396 pub pr_default_remote: Option<String>,
398 pub submodules_init: Option<SubmoduleInit>,
400 pub submodules_seed: Option<SubmoduleSeed>,
402 pub create_reflink: Option<CreateReflink>,
404 pub agent_generation_provider: Option<AgentKind>,
406 pub agent_generation_model: Option<AgentModel>,
408 pub agent_generation_effort: Option<Effort>,
410 pub list_show_untracked: Option<bool>,
412 pub list_columns: Option<Vec<Column>>,
414 pub ui_nerd_fonts: Option<bool>,
416 pub ui_mouse: Option<bool>,
418 pub ui_color: Option<ColorChoice>,
420 #[cfg(feature = "tui")]
422 pub ui_theme: Option<ThemePreset>,
423 #[cfg(feature = "tui")]
425 pub theme_overrides: ThemeOverrides,
426 #[cfg(feature = "tui")]
428 pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
429}
430
431#[cfg(feature = "tui")]
434#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
435pub struct ThemeOverrides {
436 pub accent: Option<Color>,
438 pub green: Option<Color>,
440 pub red: Option<Color>,
442 pub yellow: Option<Color>,
444 pub orange: Option<Color>,
446 pub cyan: Option<Color>,
448 pub magenta: Option<Color>,
450 pub gray: Option<Color>,
452 pub selection_bg: Option<Color>,
454 pub chip_fg: Option<Color>,
456}
457
458#[cfg(feature = "tui")]
459impl ThemeOverrides {
460 pub fn merge(&mut self, other: ThemeOverrides) {
462 self.accent = other.accent.or(self.accent);
463 self.green = other.green.or(self.green);
464 self.red = other.red.or(self.red);
465 self.yellow = other.yellow.or(self.yellow);
466 self.orange = other.orange.or(self.orange);
467 self.cyan = other.cyan.or(self.cyan);
468 self.magenta = other.magenta.or(self.magenta);
469 self.gray = other.gray.or(self.gray);
470 self.selection_bg = other.selection_bg.or(self.selection_bg);
471 self.chip_fg = other.chip_fg.or(self.chip_fg);
472 }
473
474 fn apply_to(&self, palette: &mut Palette) {
476 if let Some(c) = self.accent {
477 palette.accent = c;
478 }
479 if let Some(c) = self.green {
480 palette.green = c;
481 }
482 if let Some(c) = self.red {
483 palette.red = c;
484 }
485 if let Some(c) = self.yellow {
486 palette.yellow = c;
487 }
488 if let Some(c) = self.orange {
489 palette.orange = c;
490 }
491 if let Some(c) = self.cyan {
492 palette.cyan = c;
493 }
494 if let Some(c) = self.magenta {
495 palette.magenta = c;
496 }
497 if let Some(c) = self.gray {
498 palette.gray = c;
499 }
500 if let Some(c) = self.selection_bg {
501 palette.selection_bg = c;
502 }
503 if let Some(c) = self.chip_fg {
504 palette.chip_fg = c;
505 }
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 #[cfg(feature = "tui")]
513 use crossterm::event::KeyCode;
514
515 #[test]
516 fn defaults_match_spec() {
517 let c = Config::default();
518 assert_eq!(c.path_template, DEFAULT_TEMPLATE);
519 assert!(c.default_base.is_none());
520 assert!(c.copy.is_empty());
521 assert!(c.remove_delete_merged_branch);
522 assert!(!c.remove_untracked_blocks);
523 assert_eq!(c.pr_default_remote, "origin");
524 assert_eq!(c.submodules_init, SubmoduleInit::Prompt);
525 assert_eq!(c.agent_generation.provider, AgentKind::Claude);
526 assert_eq!(c.agent_generation.model, AgentModel::Sonnet);
527 assert_eq!(c.agent_generation.effort, Effort::Medium);
528 assert!(c.list_show_untracked);
529 assert_eq!(c.list_columns, Column::ALL.to_vec());
530 assert!(!c.ui_nerd_fonts);
531 assert!(c.ui_mouse);
532 assert_eq!(c.ui_color, ColorChoice::Auto);
533 }
534
535 #[test]
536 fn scalars_replace_on_apply() {
537 let mut c = Config::default();
538 c.apply(ConfigLayer {
539 pr_default_remote: Some("upstream".into()),
540 ui_mouse: Some(false),
541 ..Default::default()
542 });
543 assert_eq!(c.pr_default_remote, "upstream");
544 assert!(!c.ui_mouse);
545 assert!(c.list_show_untracked);
547 }
548
549 #[test]
550 fn arrays_replace_wholesale() {
551 let mut c = Config::default();
552 c.apply(ConfigLayer {
553 copy: Some(vec![".env".into()]),
554 list_columns: Some(vec![Column::Branch, Column::Pr]),
555 ..Default::default()
556 });
557 assert_eq!(c.copy, vec![".env".to_string()]);
558 assert_eq!(c.list_columns, vec![Column::Branch, Column::Pr]);
559 c.apply(ConfigLayer {
561 copy: Some(vec![".envrc".into()]),
562 ..Default::default()
563 });
564 assert_eq!(c.copy, vec![".envrc".to_string()]);
565 }
566
567 #[test]
568 fn apply_sets_every_scalar_and_optional_field() {
569 let mut c = Config::default();
570 c.apply(ConfigLayer {
571 path_template: Some("{home}/{branch_slug}".into()),
572 default_base: Some("trunk".into()),
573 editor: Some("hx".into()),
574 hooks_post_create: Some("setup".into()),
575 hooks_pre_remove: Some("teardown".into()),
576 remove_delete_merged_branch: Some(false),
577 remove_untracked_blocks: Some(true),
578 submodules_init: Some(SubmoduleInit::Always),
579 agent_generation_model: Some(AgentModel::Haiku),
580 agent_generation_effort: Some(Effort::Low),
581 list_show_untracked: Some(false),
582 ui_nerd_fonts: Some(true),
583 ui_color: Some(ColorChoice::Never),
584 ..Default::default()
585 });
586 assert_eq!(c.path_template, "{home}/{branch_slug}");
587 assert_eq!(c.default_base.as_deref(), Some("trunk"));
588 assert_eq!(c.editor.as_deref(), Some("hx"));
589 assert_eq!(c.hooks_post_create.as_deref(), Some("setup"));
590 assert_eq!(c.hooks_pre_remove.as_deref(), Some("teardown"));
591 assert!(!c.remove_delete_merged_branch);
592 assert!(c.remove_untracked_blocks);
593 assert_eq!(c.submodules_init, SubmoduleInit::Always);
594 assert_eq!(c.agent_generation.model, AgentModel::Haiku);
595 assert_eq!(c.agent_generation.effort, Effort::Low);
596 assert!(!c.list_show_untracked);
597 assert!(c.ui_nerd_fonts);
598 assert_eq!(c.ui_color, ColorChoice::Never);
599 }
600
601 #[test]
602 fn color_enabled_follows_precedence() {
603 use crate::output::color::ColorChoice;
604 let mut c = Config::default();
605 let no_env = Env::from_map(std::collections::HashMap::new());
606 assert!(c.color_enabled(None, &no_env, true));
608 assert!(!c.color_enabled(None, &no_env, false));
609 c.ui_color = ColorChoice::Never;
611 assert!(!c.color_enabled(None, &no_env, true));
612 assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
614 c.ui_color = ColorChoice::Always;
616 let no_color = Env::from_map(
617 [("NO_COLOR".to_string(), "1".to_string())]
618 .into_iter()
619 .collect(),
620 );
621 assert!(!c.color_enabled(None, &no_color, true));
622 }
623
624 #[cfg(feature = "tui")]
625 #[test]
626 fn keybindings_deep_merge_per_action() {
627 let mut c = Config::default();
628 c.apply(ConfigLayer {
630 ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
631 ..Default::default()
632 });
633 c.apply(ConfigLayer {
635 ui_keybindings: vec![
636 (KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('e'))),
637 (KeyAction::Quit, KeyChord::key(KeyCode::Char('x'))),
638 ],
639 ..Default::default()
640 });
641 let km = c.keymap();
642 assert_eq!(
644 km.action_for(KeyChord::key(KeyCode::Char('e'))),
645 Some(KeyAction::NavigateUp)
646 );
647 assert_eq!(km.action_for(KeyChord::key(KeyCode::Char('w'))), None);
648 assert_eq!(
650 km.action_for(KeyChord::key(KeyCode::Char('x'))),
651 Some(KeyAction::Quit)
652 );
653 assert_eq!(
654 km.action_for(KeyChord::key(KeyCode::Char('n'))),
655 Some(KeyAction::New)
656 );
657 }
658
659 #[cfg(feature = "tui")]
660 #[test]
661 fn theme_defaults_to_one_dark() {
662 let c = Config::default();
663 assert_eq!(c.ui_theme, ThemePreset::OneDark);
664 assert_eq!(c.theme_overrides, ThemeOverrides::default());
665 assert_eq!(c.palette(), Palette::one_dark());
666 }
667
668 #[cfg(feature = "tui")]
669 #[test]
670 fn theme_preset_and_overrides_apply_and_merge() {
671 let mut c = Config::default();
672 c.apply(ConfigLayer {
674 ui_theme: Some(ThemePreset::Solarized),
675 theme_overrides: ThemeOverrides {
676 accent: Some(Color::Rgb(1, 1, 1)),
677 ..Default::default()
678 },
679 ..Default::default()
680 });
681 c.apply(ConfigLayer {
683 theme_overrides: ThemeOverrides {
684 red: Some(Color::Rgb(2, 2, 2)),
685 ..Default::default()
686 },
687 ..Default::default()
688 });
689 assert_eq!(c.ui_theme, ThemePreset::Solarized);
690 let p = c.palette();
691 assert_eq!(p.accent, Color::Rgb(1, 1, 1));
693 assert_eq!(p.red, Color::Rgb(2, 2, 2));
694 assert_eq!(p.green, Palette::solarized().green);
696 }
697
698 #[cfg(feature = "tui")]
699 #[test]
700 fn later_theme_override_wins_for_same_slot() {
701 let mut o = ThemeOverrides {
702 accent: Some(Color::Rgb(1, 1, 1)),
703 ..Default::default()
704 };
705 o.merge(ThemeOverrides {
706 accent: Some(Color::Rgb(9, 9, 9)),
707 ..Default::default()
708 });
709 assert_eq!(o.accent, Some(Color::Rgb(9, 9, 9)));
710 }
711}