Skip to main content

wt/config/
schema.rs

1//! The resolved [`Config`] and the per-layer [`ConfigLayer`], plus the merge
2//! semantics (spec §11).
3
4#[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/// When to initialize git submodules after a worktree is created or a branch is
18/// checked out (`[submodules] init`, issue #50). The default ([`Prompt`]) asks
19/// before initializing at an interactive terminal; `always`/`never` decide
20/// without a prompt.
21///
22/// [`Prompt`]: SubmoduleInit::Prompt
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
24pub enum SubmoduleInit {
25    /// Ask before initializing (the default): at an interactive terminal, prompt
26    /// `[Y/n]` (defaulting to yes) when uninitialized submodules are present;
27    /// non-interactively, leave them alone.
28    #[default]
29    Prompt,
30    /// Never initialize submodules automatically.
31    Never,
32    /// Always run `git submodule update --init --recursive` when uninitialized
33    /// submodules are present.
34    Always,
35}
36
37impl SubmoduleInit {
38    /// Parses a `submodules.init` value (`prompt`, `never`, `always`).
39    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    /// The config identifier this policy round-trips through.
49    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/// Whether to populate a new worktree's submodules from the repository's own
59/// local object stores instead of re-cloning them (`[submodules] seed`).
60///
61/// A linked worktree does not share `.git/modules`, so initializing submodules
62/// in one normally re-clones every submodule over the network. Seeding clones
63/// from those existing mirrors instead, which hardlinks the objects.
64///
65/// Seeding never changes the outcome, only the cost: it is always followed by a
66/// stock `git submodule update --init --recursive`, so anything it skips or gets
67/// wrong is reconciled by git itself. That is why [`Auto`] is the default.
68///
69/// [`Auto`]: SubmoduleSeed::Auto
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub enum SubmoduleSeed {
72    /// Seed from a local mirror whenever one exists (the default).
73    #[default]
74    Auto,
75    /// Never seed; always let git clone submodules from their remotes.
76    Never,
77}
78
79impl SubmoduleSeed {
80    /// Parses a `submodules.seed` value (`auto`, `never`).
81    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    /// Whether seeding is enabled.
90    pub fn is_enabled(self) -> bool {
91        matches!(self, SubmoduleSeed::Auto)
92    }
93
94    /// The config identifier this strategy round-trips through.
95    pub fn identifier(self) -> &'static str {
96        match self {
97            SubmoduleSeed::Auto => "auto",
98            SubmoduleSeed::Never => "never",
99        }
100    }
101}
102
103/// Whether to materialize a new worktree by copy-on-write cloning an existing
104/// one's files instead of checking them out (`[create] reflink`).
105///
106/// On a CoW filesystem (btrfs, XFS with `reflink=1`, APFS, ReFS) this shares the
107/// extents of every file, so a new worktree of a large repository costs almost
108/// no disk and almost no writing. It also carries the source's ignored build
109/// output along, which is usually the point and occasionally not. The source's
110/// untracked files are left behind, so the new worktree still comes up clean and
111/// [`Config::copy`] still governs which non-tracked files travel.
112///
113/// Off by default: it only pays off on a CoW filesystem, and copying a source's
114/// ignored files is a bigger behavioural change than seeding.
115/// `auto` uses it when the filesystem supports it and a source worktree is
116/// already at the same tree, and silently checks out normally otherwise.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
118pub enum CreateReflink {
119    /// Never clone; always check out (the default).
120    #[default]
121    Never,
122    /// Clone when the filesystem and the source worktree allow it.
123    Auto,
124}
125
126impl CreateReflink {
127    /// Parses a `create.reflink` value (`auto`, `never`).
128    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    /// Whether copy-on-write materialization is enabled.
137    pub fn is_enabled(self) -> bool {
138        matches!(self, CreateReflink::Auto)
139    }
140
141    /// The config identifier this strategy round-trips through.
142    pub fn identifier(self) -> &'static str {
143        match self {
144            CreateReflink::Auto => "auto",
145            CreateReflink::Never => "never",
146        }
147    }
148}
149
150/// The generation agent profile (`[agent.generation]`): which agent `wt` drives
151/// for the short, structured generation steps it owns — the PR auto-fill and
152/// the `wt issue` branch/brief proposal.
153///
154/// `wt` owns *generation* only. Running a coding agent on the work itself is
155/// karet's job, which is why there is no work profile here and why
156/// `[agent.work]` is rejected with a pointer to karet.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct GenerationAgentConfig {
159    /// Which agent CLI to drive.
160    pub provider: AgentKind,
161    /// The model to request.
162    pub model: AgentModel,
163    /// The reasoning effort to request.
164    pub effort: Effort,
165}
166
167impl Default for GenerationAgentConfig {
168    fn default() -> Self {
169        // Unchanged from the flat `[agent]` defaults these keys replace, so
170        // `wt pr open --ai` behaves identically before and after the move.
171        GenerationAgentConfig {
172            provider: AgentKind::Claude,
173            model: AgentModel::default(),
174            effort: Effort::default(),
175        }
176    }
177}
178
179/// The fully-resolved configuration after merging all layers.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Config {
182    /// Worktree-store path template (spec §6).
183    pub path_template: String,
184    /// Base ref for `new` when a branch is created; `None` resolves the repo's
185    /// default branch at runtime.
186    pub default_base: Option<String>,
187    /// Glob patterns to copy into new worktrees (spec §8).
188    pub copy: Vec<String>,
189    /// Shell command run after worktree creation.
190    pub hooks_post_create: Option<String>,
191    /// Shell command run before worktree removal.
192    pub hooks_pre_remove: Option<String>,
193    /// Editor command; `None` falls back to `$VISUAL`/`$EDITOR`.
194    pub editor: Option<String>,
195    /// Delete a wt-created branch on `remove` if fully merged.
196    pub remove_delete_merged_branch: bool,
197    /// Whether untracked files count as dirty for the remove guard. Prune always
198    /// counts them.
199    pub remove_untracked_blocks: bool,
200    /// Remote used for PR fetches.
201    pub pr_default_remote: String,
202    /// When to auto-initialize git submodules on create/checkout (issue #50).
203    pub submodules_init: SubmoduleInit,
204    /// Whether to seed a new worktree's submodules from the repository's own
205    /// local object stores instead of re-cloning them.
206    pub submodules_seed: SubmoduleSeed,
207    /// Whether to copy-on-write clone a new worktree's content from an existing
208    /// worktree instead of checking it out.
209    pub create_reflink: CreateReflink,
210    /// The generation agent profile (`[agent.generation]`), used by the AI PR
211    /// auto-fill (`wt pr open --ai`) and by `wt issue`. Model and effort are
212    /// overridable per-invocation by `--model`/`--effort` or the TUI's
213    /// `Ctrl-M`/`Ctrl-E` keys.
214    pub agent_generation: GenerationAgentConfig,
215    /// Show `?` in the dirty column for untracked files.
216    pub list_show_untracked: bool,
217    /// Ordered list of columns to display in `wt list`.
218    pub list_columns: Vec<Column>,
219    /// Enable Nerd Font glyphs in the TUI.
220    pub ui_nerd_fonts: bool,
221    /// Enable mouse support in the TUI.
222    pub ui_mouse: bool,
223    /// Color output setting (reconciled with `--color`/`NO_COLOR`).
224    pub ui_color: ColorChoice,
225    /// Built-in theme preset (the base TUI palette).
226    #[cfg(feature = "tui")]
227    pub ui_theme: ThemePreset,
228    /// Per-color overrides layered on top of the preset (`[ui.theme]`).
229    #[cfg(feature = "tui")]
230    pub theme_overrides: ThemeOverrides,
231    /// Accumulated `ui.keybindings` overrides (applied over the defaults).
232    #[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    /// Applies a parsed layer on top of this config (spec §11 merge semantics):
269    /// scalars replace, arrays (`copy`, `list.columns`) replace wholesale,
270    /// `ui.keybindings` deep-merges per action, and the `[ui.theme]` colors
271    /// deep-merge per slot (the `preset` is a scalar). Overrides accumulate in
272    /// apply order, so a later layer wins.
273    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    /// Resolves the effective TUI [`Palette`]: the selected preset's base palette
345    /// with any `[ui.theme]` per-color overrides applied on top.
346    #[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    /// Builds the effective TUI keymap: the defaults with the configured
354    /// overrides applied in order.
355    #[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    /// Resolves whether to emit color, reconciling the `--color` flag, the
365    /// `NO_COLOR` env var, and `ui.color` (spec §11 precedence).
366    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/// One configuration layer (a single file's settings, or flags); every field is
377/// optional and only present keys override lower layers.
378#[derive(Debug, Clone, Default, PartialEq, Eq)]
379pub struct ConfigLayer {
380    /// `path_template`.
381    pub path_template: Option<String>,
382    /// `default_base`.
383    pub default_base: Option<String>,
384    /// `copy`.
385    pub copy: Option<Vec<String>>,
386    /// `editor`.
387    pub editor: Option<String>,
388    /// `hooks.post_create`.
389    pub hooks_post_create: Option<String>,
390    /// `hooks.pre_remove`.
391    pub hooks_pre_remove: Option<String>,
392    /// `remove.delete_merged_branch`.
393    pub remove_delete_merged_branch: Option<bool>,
394    /// `remove.untracked_blocks`.
395    pub remove_untracked_blocks: Option<bool>,
396    /// `pr.default_remote`.
397    pub pr_default_remote: Option<String>,
398    /// `submodules.init`.
399    pub submodules_init: Option<SubmoduleInit>,
400    /// `submodules.seed`.
401    pub submodules_seed: Option<SubmoduleSeed>,
402    /// `create.reflink`.
403    pub create_reflink: Option<CreateReflink>,
404    /// `agent.generation.provider`.
405    pub agent_generation_provider: Option<AgentKind>,
406    /// `agent.generation.model`, or its deprecated `agent.model` alias.
407    pub agent_generation_model: Option<AgentModel>,
408    /// `agent.generation.effort`, or its deprecated `agent.effort` alias.
409    pub agent_generation_effort: Option<Effort>,
410    /// `list.show_untracked`.
411    pub list_show_untracked: Option<bool>,
412    /// `list.columns`.
413    pub list_columns: Option<Vec<Column>>,
414    /// `ui.nerd_fonts`.
415    pub ui_nerd_fonts: Option<bool>,
416    /// `ui.mouse`.
417    pub ui_mouse: Option<bool>,
418    /// `ui.color`.
419    pub ui_color: Option<ColorChoice>,
420    /// `ui.theme.preset`.
421    #[cfg(feature = "tui")]
422    pub ui_theme: Option<ThemePreset>,
423    /// `[ui.theme]` per-color overrides present in this layer.
424    #[cfg(feature = "tui")]
425    pub theme_overrides: ThemeOverrides,
426    /// `ui.keybindings` (action → chord) overrides.
427    #[cfg(feature = "tui")]
428    pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
429}
430
431/// Per-color overrides for the TUI palette (`[ui.theme]`). Each field mirrors a
432/// [`Palette`] slot; `None` leaves the preset's color untouched.
433#[cfg(feature = "tui")]
434#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
435pub struct ThemeOverrides {
436    /// `ui.theme.accent`.
437    pub accent: Option<Color>,
438    /// `ui.theme.green`.
439    pub green: Option<Color>,
440    /// `ui.theme.red`.
441    pub red: Option<Color>,
442    /// `ui.theme.yellow`.
443    pub yellow: Option<Color>,
444    /// `ui.theme.orange`.
445    pub orange: Option<Color>,
446    /// `ui.theme.cyan`.
447    pub cyan: Option<Color>,
448    /// `ui.theme.magenta`.
449    pub magenta: Option<Color>,
450    /// `ui.theme.gray`.
451    pub gray: Option<Color>,
452    /// `ui.theme.selection_bg`.
453    pub selection_bg: Option<Color>,
454    /// `ui.theme.chip_fg`.
455    pub chip_fg: Option<Color>,
456}
457
458#[cfg(feature = "tui")]
459impl ThemeOverrides {
460    /// Merges another layer's overrides on top of these (set slots win).
461    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    /// Applies the set overrides onto a base [`Palette`].
475    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        // Untouched fields keep their defaults.
546        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        // A second layer replaces, not concatenates.
560        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        // Default ui.color=auto -> follows stdout TTY.
607        assert!(c.color_enabled(None, &no_env, true));
608        assert!(!c.color_enabled(None, &no_env, false));
609        // ui.color=never overrides auto.
610        c.ui_color = ColorChoice::Never;
611        assert!(!c.color_enabled(None, &no_env, true));
612        // --color always beats config.
613        assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
614        // NO_COLOR beats config 'always'.
615        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        // Global layer rebinds navigate-up.
629        c.apply(ConfigLayer {
630            ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
631            ..Default::default()
632        });
633        // Per-repo layer rebinds navigate-up again, plus quit.
634        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        // Per-repo wins for navigate-up.
643        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        // Quit rebound, but unrelated actions keep their defaults.
649        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        // Global layer: solarized preset + an accent override.
673        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        // Per-repo layer: override red only; preset and accent untouched.
682        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        // Both overrides survive (deep-merge per slot).
692        assert_eq!(p.accent, Color::Rgb(1, 1, 1));
693        assert_eq!(p.red, Color::Rgb(2, 2, 2));
694        // A non-overridden slot keeps the solarized base.
695        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}