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 remove/prune guards.
198    pub remove_untracked_blocks: bool,
199    /// Remote used for PR fetches.
200    pub pr_default_remote: String,
201    /// When to auto-initialize git submodules on create/checkout (issue #50).
202    pub submodules_init: SubmoduleInit,
203    /// Whether to seed a new worktree's submodules from the repository's own
204    /// local object stores instead of re-cloning them.
205    pub submodules_seed: SubmoduleSeed,
206    /// Whether to copy-on-write clone a new worktree's content from an existing
207    /// worktree instead of checking it out.
208    pub create_reflink: CreateReflink,
209    /// The generation agent profile (`[agent.generation]`), used by the AI PR
210    /// auto-fill (`wt pr open --ai`) and by `wt issue`. Model and effort are
211    /// overridable per-invocation by `--model`/`--effort` or the TUI's
212    /// `Ctrl-M`/`Ctrl-E` keys.
213    pub agent_generation: GenerationAgentConfig,
214    /// Show `?` in the dirty column for untracked files.
215    pub list_show_untracked: bool,
216    /// Ordered list of columns to display in `wt list`.
217    pub list_columns: Vec<Column>,
218    /// Enable Nerd Font glyphs in the TUI.
219    pub ui_nerd_fonts: bool,
220    /// Enable mouse support in the TUI.
221    pub ui_mouse: bool,
222    /// Color output setting (reconciled with `--color`/`NO_COLOR`).
223    pub ui_color: ColorChoice,
224    /// Built-in theme preset (the base TUI palette).
225    #[cfg(feature = "tui")]
226    pub ui_theme: ThemePreset,
227    /// Per-color overrides layered on top of the preset (`[ui.theme]`).
228    #[cfg(feature = "tui")]
229    pub theme_overrides: ThemeOverrides,
230    /// Accumulated `ui.keybindings` overrides (applied over the defaults).
231    #[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    /// Applies a parsed layer on top of this config (spec §11 merge semantics):
268    /// scalars replace, arrays (`copy`, `list.columns`) replace wholesale,
269    /// `ui.keybindings` deep-merges per action, and the `[ui.theme]` colors
270    /// deep-merge per slot (the `preset` is a scalar). Overrides accumulate in
271    /// apply order, so a later layer wins.
272    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    /// Resolves the effective TUI [`Palette`]: the selected preset's base palette
344    /// with any `[ui.theme]` per-color overrides applied on top.
345    #[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    /// Builds the effective TUI keymap: the defaults with the configured
353    /// overrides applied in order.
354    #[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    /// Resolves whether to emit color, reconciling the `--color` flag, the
364    /// `NO_COLOR` env var, and `ui.color` (spec §11 precedence).
365    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/// One configuration layer (a single file's settings, or flags); every field is
376/// optional and only present keys override lower layers.
377#[derive(Debug, Clone, Default, PartialEq, Eq)]
378pub struct ConfigLayer {
379    /// `path_template`.
380    pub path_template: Option<String>,
381    /// `default_base`.
382    pub default_base: Option<String>,
383    /// `copy`.
384    pub copy: Option<Vec<String>>,
385    /// `editor`.
386    pub editor: Option<String>,
387    /// `hooks.post_create`.
388    pub hooks_post_create: Option<String>,
389    /// `hooks.pre_remove`.
390    pub hooks_pre_remove: Option<String>,
391    /// `remove.delete_merged_branch`.
392    pub remove_delete_merged_branch: Option<bool>,
393    /// `remove.untracked_blocks`.
394    pub remove_untracked_blocks: Option<bool>,
395    /// `pr.default_remote`.
396    pub pr_default_remote: Option<String>,
397    /// `submodules.init`.
398    pub submodules_init: Option<SubmoduleInit>,
399    /// `submodules.seed`.
400    pub submodules_seed: Option<SubmoduleSeed>,
401    /// `create.reflink`.
402    pub create_reflink: Option<CreateReflink>,
403    /// `agent.generation.provider`.
404    pub agent_generation_provider: Option<AgentKind>,
405    /// `agent.generation.model`, or its deprecated `agent.model` alias.
406    pub agent_generation_model: Option<AgentModel>,
407    /// `agent.generation.effort`, or its deprecated `agent.effort` alias.
408    pub agent_generation_effort: Option<Effort>,
409    /// `list.show_untracked`.
410    pub list_show_untracked: Option<bool>,
411    /// `list.columns`.
412    pub list_columns: Option<Vec<Column>>,
413    /// `ui.nerd_fonts`.
414    pub ui_nerd_fonts: Option<bool>,
415    /// `ui.mouse`.
416    pub ui_mouse: Option<bool>,
417    /// `ui.color`.
418    pub ui_color: Option<ColorChoice>,
419    /// `ui.theme.preset`.
420    #[cfg(feature = "tui")]
421    pub ui_theme: Option<ThemePreset>,
422    /// `[ui.theme]` per-color overrides present in this layer.
423    #[cfg(feature = "tui")]
424    pub theme_overrides: ThemeOverrides,
425    /// `ui.keybindings` (action → chord) overrides.
426    #[cfg(feature = "tui")]
427    pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
428}
429
430/// Per-color overrides for the TUI palette (`[ui.theme]`). Each field mirrors a
431/// [`Palette`] slot; `None` leaves the preset's color untouched.
432#[cfg(feature = "tui")]
433#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
434pub struct ThemeOverrides {
435    /// `ui.theme.accent`.
436    pub accent: Option<Color>,
437    /// `ui.theme.green`.
438    pub green: Option<Color>,
439    /// `ui.theme.red`.
440    pub red: Option<Color>,
441    /// `ui.theme.yellow`.
442    pub yellow: Option<Color>,
443    /// `ui.theme.orange`.
444    pub orange: Option<Color>,
445    /// `ui.theme.cyan`.
446    pub cyan: Option<Color>,
447    /// `ui.theme.magenta`.
448    pub magenta: Option<Color>,
449    /// `ui.theme.gray`.
450    pub gray: Option<Color>,
451    /// `ui.theme.selection_bg`.
452    pub selection_bg: Option<Color>,
453    /// `ui.theme.chip_fg`.
454    pub chip_fg: Option<Color>,
455}
456
457#[cfg(feature = "tui")]
458impl ThemeOverrides {
459    /// Merges another layer's overrides on top of these (set slots win).
460    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    /// Applies the set overrides onto a base [`Palette`].
474    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        // Untouched fields keep their defaults.
545        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        // A second layer replaces, not concatenates.
559        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        // Default ui.color=auto -> follows stdout TTY.
606        assert!(c.color_enabled(None, &no_env, true));
607        assert!(!c.color_enabled(None, &no_env, false));
608        // ui.color=never overrides auto.
609        c.ui_color = ColorChoice::Never;
610        assert!(!c.color_enabled(None, &no_env, true));
611        // --color always beats config.
612        assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
613        // NO_COLOR beats config 'always'.
614        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        // Global layer rebinds navigate-up.
628        c.apply(ConfigLayer {
629            ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
630            ..Default::default()
631        });
632        // Per-repo layer rebinds navigate-up again, plus quit.
633        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        // Per-repo wins for navigate-up.
642        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        // Quit rebound, but unrelated actions keep their defaults.
648        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        // Global layer: solarized preset + an accent override.
672        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        // Per-repo layer: override red only; preset and accent untouched.
681        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        // Both overrides survive (deep-merge per slot).
691        assert_eq!(p.accent, Color::Rgb(1, 1, 1));
692        assert_eq!(p.red, Color::Rgb(2, 2, 2));
693        // A non-overridden slot keeps the solarized base.
694        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}