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,
199 pub pr_default_remote: String,
201 pub submodules_init: SubmoduleInit,
203 pub submodules_seed: SubmoduleSeed,
206 pub create_reflink: CreateReflink,
209 pub agent_generation: GenerationAgentConfig,
214 pub list_show_untracked: bool,
216 pub list_columns: Vec<Column>,
218 pub ui_nerd_fonts: bool,
220 pub ui_mouse: bool,
222 pub ui_color: ColorChoice,
224 #[cfg(feature = "tui")]
226 pub ui_theme: ThemePreset,
227 #[cfg(feature = "tui")]
229 pub theme_overrides: ThemeOverrides,
230 #[cfg(feature = "tui")]
232 pub keybinding_overrides: Vec<(KeyAction, KeyChord)>,
233}
234
235impl Default for Config {
236 fn default() -> Self {
237 Config {
238 path_template: DEFAULT_TEMPLATE.to_string(),
239 default_base: None,
240 copy: Vec::new(),
241 hooks_post_create: None,
242 hooks_pre_remove: None,
243 editor: None,
244 remove_delete_merged_branch: true,
245 remove_untracked_blocks: false,
246 pr_default_remote: "origin".to_string(),
247 submodules_init: SubmoduleInit::default(),
248 submodules_seed: SubmoduleSeed::default(),
249 create_reflink: CreateReflink::default(),
250 agent_generation: GenerationAgentConfig::default(),
251 list_show_untracked: true,
252 list_columns: Column::ALL.to_vec(),
253 ui_nerd_fonts: false,
254 ui_mouse: true,
255 ui_color: ColorChoice::Auto,
256 #[cfg(feature = "tui")]
257 ui_theme: ThemePreset::default(),
258 #[cfg(feature = "tui")]
259 theme_overrides: ThemeOverrides::default(),
260 #[cfg(feature = "tui")]
261 keybinding_overrides: Vec::new(),
262 }
263 }
264}
265
266impl Config {
267 pub fn apply(&mut self, layer: ConfigLayer) {
273 if let Some(v) = layer.path_template {
274 self.path_template = v;
275 }
276 if let Some(v) = layer.default_base {
277 self.default_base = Some(v);
278 }
279 if let Some(v) = layer.copy {
280 self.copy = v;
281 }
282 if let Some(v) = layer.editor {
283 self.editor = Some(v);
284 }
285 if let Some(v) = layer.hooks_post_create {
286 self.hooks_post_create = Some(v);
287 }
288 if let Some(v) = layer.hooks_pre_remove {
289 self.hooks_pre_remove = Some(v);
290 }
291 if let Some(v) = layer.remove_delete_merged_branch {
292 self.remove_delete_merged_branch = v;
293 }
294 if let Some(v) = layer.remove_untracked_blocks {
295 self.remove_untracked_blocks = v;
296 }
297 if let Some(v) = layer.pr_default_remote {
298 self.pr_default_remote = v;
299 }
300 if let Some(v) = layer.submodules_init {
301 self.submodules_init = v;
302 }
303 if let Some(v) = layer.submodules_seed {
304 self.submodules_seed = v;
305 }
306 if let Some(v) = layer.create_reflink {
307 self.create_reflink = v;
308 }
309 if let Some(v) = layer.agent_generation_provider {
310 self.agent_generation.provider = v;
311 }
312 if let Some(v) = layer.agent_generation_model {
313 self.agent_generation.model = v;
314 }
315 if let Some(v) = layer.agent_generation_effort {
316 self.agent_generation.effort = v;
317 }
318 if let Some(v) = layer.list_show_untracked {
319 self.list_show_untracked = v;
320 }
321 if let Some(v) = layer.list_columns {
322 self.list_columns = v;
323 }
324 if let Some(v) = layer.ui_nerd_fonts {
325 self.ui_nerd_fonts = v;
326 }
327 if let Some(v) = layer.ui_mouse {
328 self.ui_mouse = v;
329 }
330 if let Some(v) = layer.ui_color {
331 self.ui_color = v;
332 }
333 #[cfg(feature = "tui")]
334 {
335 if let Some(v) = layer.ui_theme {
336 self.ui_theme = v;
337 }
338 self.theme_overrides.merge(layer.theme_overrides);
339 self.keybinding_overrides.extend(layer.ui_keybindings);
340 }
341 }
342
343 #[cfg(feature = "tui")]
346 pub fn palette(&self) -> Palette {
347 let mut palette = self.ui_theme.palette();
348 self.theme_overrides.apply_to(&mut palette);
349 palette
350 }
351
352 #[cfg(feature = "tui")]
355 pub fn keymap(&self) -> Keymap {
356 let mut keymap = Keymap::defaults();
357 for (action, chord) in &self.keybinding_overrides {
358 keymap.rebind(*action, *chord);
359 }
360 keymap
361 }
362
363 pub fn color_enabled(&self, flag: Option<ColorChoice>, env: &Env, stdout_is_tty: bool) -> bool {
366 resolve_color(
367 flag,
368 env.is_set_nonempty("NO_COLOR"),
369 Some(self.ui_color),
370 stdout_is_tty,
371 )
372 }
373}
374
375#[derive(Debug, Clone, Default, PartialEq, Eq)]
378pub struct ConfigLayer {
379 pub path_template: Option<String>,
381 pub default_base: Option<String>,
383 pub copy: Option<Vec<String>>,
385 pub editor: Option<String>,
387 pub hooks_post_create: Option<String>,
389 pub hooks_pre_remove: Option<String>,
391 pub remove_delete_merged_branch: Option<bool>,
393 pub remove_untracked_blocks: Option<bool>,
395 pub pr_default_remote: Option<String>,
397 pub submodules_init: Option<SubmoduleInit>,
399 pub submodules_seed: Option<SubmoduleSeed>,
401 pub create_reflink: Option<CreateReflink>,
403 pub agent_generation_provider: Option<AgentKind>,
405 pub agent_generation_model: Option<AgentModel>,
407 pub agent_generation_effort: Option<Effort>,
409 pub list_show_untracked: Option<bool>,
411 pub list_columns: Option<Vec<Column>>,
413 pub ui_nerd_fonts: Option<bool>,
415 pub ui_mouse: Option<bool>,
417 pub ui_color: Option<ColorChoice>,
419 #[cfg(feature = "tui")]
421 pub ui_theme: Option<ThemePreset>,
422 #[cfg(feature = "tui")]
424 pub theme_overrides: ThemeOverrides,
425 #[cfg(feature = "tui")]
427 pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
428}
429
430#[cfg(feature = "tui")]
433#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
434pub struct ThemeOverrides {
435 pub accent: Option<Color>,
437 pub green: Option<Color>,
439 pub red: Option<Color>,
441 pub yellow: Option<Color>,
443 pub orange: Option<Color>,
445 pub cyan: Option<Color>,
447 pub magenta: Option<Color>,
449 pub gray: Option<Color>,
451 pub selection_bg: Option<Color>,
453 pub chip_fg: Option<Color>,
455}
456
457#[cfg(feature = "tui")]
458impl ThemeOverrides {
459 pub fn merge(&mut self, other: ThemeOverrides) {
461 self.accent = other.accent.or(self.accent);
462 self.green = other.green.or(self.green);
463 self.red = other.red.or(self.red);
464 self.yellow = other.yellow.or(self.yellow);
465 self.orange = other.orange.or(self.orange);
466 self.cyan = other.cyan.or(self.cyan);
467 self.magenta = other.magenta.or(self.magenta);
468 self.gray = other.gray.or(self.gray);
469 self.selection_bg = other.selection_bg.or(self.selection_bg);
470 self.chip_fg = other.chip_fg.or(self.chip_fg);
471 }
472
473 fn apply_to(&self, palette: &mut Palette) {
475 if let Some(c) = self.accent {
476 palette.accent = c;
477 }
478 if let Some(c) = self.green {
479 palette.green = c;
480 }
481 if let Some(c) = self.red {
482 palette.red = c;
483 }
484 if let Some(c) = self.yellow {
485 palette.yellow = c;
486 }
487 if let Some(c) = self.orange {
488 palette.orange = c;
489 }
490 if let Some(c) = self.cyan {
491 palette.cyan = c;
492 }
493 if let Some(c) = self.magenta {
494 palette.magenta = c;
495 }
496 if let Some(c) = self.gray {
497 palette.gray = c;
498 }
499 if let Some(c) = self.selection_bg {
500 palette.selection_bg = c;
501 }
502 if let Some(c) = self.chip_fg {
503 palette.chip_fg = c;
504 }
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 #[cfg(feature = "tui")]
512 use crossterm::event::KeyCode;
513
514 #[test]
515 fn defaults_match_spec() {
516 let c = Config::default();
517 assert_eq!(c.path_template, DEFAULT_TEMPLATE);
518 assert!(c.default_base.is_none());
519 assert!(c.copy.is_empty());
520 assert!(c.remove_delete_merged_branch);
521 assert!(!c.remove_untracked_blocks);
522 assert_eq!(c.pr_default_remote, "origin");
523 assert_eq!(c.submodules_init, SubmoduleInit::Prompt);
524 assert_eq!(c.agent_generation.provider, AgentKind::Claude);
525 assert_eq!(c.agent_generation.model, AgentModel::Sonnet);
526 assert_eq!(c.agent_generation.effort, Effort::Medium);
527 assert!(c.list_show_untracked);
528 assert_eq!(c.list_columns, Column::ALL.to_vec());
529 assert!(!c.ui_nerd_fonts);
530 assert!(c.ui_mouse);
531 assert_eq!(c.ui_color, ColorChoice::Auto);
532 }
533
534 #[test]
535 fn scalars_replace_on_apply() {
536 let mut c = Config::default();
537 c.apply(ConfigLayer {
538 pr_default_remote: Some("upstream".into()),
539 ui_mouse: Some(false),
540 ..Default::default()
541 });
542 assert_eq!(c.pr_default_remote, "upstream");
543 assert!(!c.ui_mouse);
544 assert!(c.list_show_untracked);
546 }
547
548 #[test]
549 fn arrays_replace_wholesale() {
550 let mut c = Config::default();
551 c.apply(ConfigLayer {
552 copy: Some(vec![".env".into()]),
553 list_columns: Some(vec![Column::Branch, Column::Pr]),
554 ..Default::default()
555 });
556 assert_eq!(c.copy, vec![".env".to_string()]);
557 assert_eq!(c.list_columns, vec![Column::Branch, Column::Pr]);
558 c.apply(ConfigLayer {
560 copy: Some(vec![".envrc".into()]),
561 ..Default::default()
562 });
563 assert_eq!(c.copy, vec![".envrc".to_string()]);
564 }
565
566 #[test]
567 fn apply_sets_every_scalar_and_optional_field() {
568 let mut c = Config::default();
569 c.apply(ConfigLayer {
570 path_template: Some("{home}/{branch_slug}".into()),
571 default_base: Some("trunk".into()),
572 editor: Some("hx".into()),
573 hooks_post_create: Some("setup".into()),
574 hooks_pre_remove: Some("teardown".into()),
575 remove_delete_merged_branch: Some(false),
576 remove_untracked_blocks: Some(true),
577 submodules_init: Some(SubmoduleInit::Always),
578 agent_generation_model: Some(AgentModel::Haiku),
579 agent_generation_effort: Some(Effort::Low),
580 list_show_untracked: Some(false),
581 ui_nerd_fonts: Some(true),
582 ui_color: Some(ColorChoice::Never),
583 ..Default::default()
584 });
585 assert_eq!(c.path_template, "{home}/{branch_slug}");
586 assert_eq!(c.default_base.as_deref(), Some("trunk"));
587 assert_eq!(c.editor.as_deref(), Some("hx"));
588 assert_eq!(c.hooks_post_create.as_deref(), Some("setup"));
589 assert_eq!(c.hooks_pre_remove.as_deref(), Some("teardown"));
590 assert!(!c.remove_delete_merged_branch);
591 assert!(c.remove_untracked_blocks);
592 assert_eq!(c.submodules_init, SubmoduleInit::Always);
593 assert_eq!(c.agent_generation.model, AgentModel::Haiku);
594 assert_eq!(c.agent_generation.effort, Effort::Low);
595 assert!(!c.list_show_untracked);
596 assert!(c.ui_nerd_fonts);
597 assert_eq!(c.ui_color, ColorChoice::Never);
598 }
599
600 #[test]
601 fn color_enabled_follows_precedence() {
602 use crate::output::color::ColorChoice;
603 let mut c = Config::default();
604 let no_env = Env::from_map(std::collections::HashMap::new());
605 assert!(c.color_enabled(None, &no_env, true));
607 assert!(!c.color_enabled(None, &no_env, false));
608 c.ui_color = ColorChoice::Never;
610 assert!(!c.color_enabled(None, &no_env, true));
611 assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
613 c.ui_color = ColorChoice::Always;
615 let no_color = Env::from_map(
616 [("NO_COLOR".to_string(), "1".to_string())]
617 .into_iter()
618 .collect(),
619 );
620 assert!(!c.color_enabled(None, &no_color, true));
621 }
622
623 #[cfg(feature = "tui")]
624 #[test]
625 fn keybindings_deep_merge_per_action() {
626 let mut c = Config::default();
627 c.apply(ConfigLayer {
629 ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
630 ..Default::default()
631 });
632 c.apply(ConfigLayer {
634 ui_keybindings: vec![
635 (KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('e'))),
636 (KeyAction::Quit, KeyChord::key(KeyCode::Char('x'))),
637 ],
638 ..Default::default()
639 });
640 let km = c.keymap();
641 assert_eq!(
643 km.action_for(KeyChord::key(KeyCode::Char('e'))),
644 Some(KeyAction::NavigateUp)
645 );
646 assert_eq!(km.action_for(KeyChord::key(KeyCode::Char('w'))), None);
647 assert_eq!(
649 km.action_for(KeyChord::key(KeyCode::Char('x'))),
650 Some(KeyAction::Quit)
651 );
652 assert_eq!(
653 km.action_for(KeyChord::key(KeyCode::Char('n'))),
654 Some(KeyAction::New)
655 );
656 }
657
658 #[cfg(feature = "tui")]
659 #[test]
660 fn theme_defaults_to_one_dark() {
661 let c = Config::default();
662 assert_eq!(c.ui_theme, ThemePreset::OneDark);
663 assert_eq!(c.theme_overrides, ThemeOverrides::default());
664 assert_eq!(c.palette(), Palette::one_dark());
665 }
666
667 #[cfg(feature = "tui")]
668 #[test]
669 fn theme_preset_and_overrides_apply_and_merge() {
670 let mut c = Config::default();
671 c.apply(ConfigLayer {
673 ui_theme: Some(ThemePreset::Solarized),
674 theme_overrides: ThemeOverrides {
675 accent: Some(Color::Rgb(1, 1, 1)),
676 ..Default::default()
677 },
678 ..Default::default()
679 });
680 c.apply(ConfigLayer {
682 theme_overrides: ThemeOverrides {
683 red: Some(Color::Rgb(2, 2, 2)),
684 ..Default::default()
685 },
686 ..Default::default()
687 });
688 assert_eq!(c.ui_theme, ThemePreset::Solarized);
689 let p = c.palette();
690 assert_eq!(p.accent, Color::Rgb(1, 1, 1));
692 assert_eq!(p.red, Color::Rgb(2, 2, 2));
693 assert_eq!(p.green, Palette::solarized().green);
695 }
696
697 #[cfg(feature = "tui")]
698 #[test]
699 fn later_theme_override_wins_for_same_slot() {
700 let mut o = ThemeOverrides {
701 accent: Some(Color::Rgb(1, 1, 1)),
702 ..Default::default()
703 };
704 o.merge(ThemeOverrides {
705 accent: Some(Color::Rgb(9, 9, 9)),
706 ..Default::default()
707 });
708 assert_eq!(o.accent, Some(Color::Rgb(9, 9, 9)));
709 }
710}