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
4use ratatui::style::Color;
5
6use crate::agent::{AgentModel, Effort};
7use crate::cx::Env;
8use crate::keys::{KeyAction, KeyChord, Keymap};
9use crate::model::Column;
10use crate::output::color::{ColorChoice, resolve_color};
11use crate::template::DEFAULT_TEMPLATE;
12use crate::tui::theme::{Palette, ThemePreset};
13
14/// When to initialize git submodules after a worktree is created or a branch is
15/// checked out (`[submodules] init`, issue #50). The default ([`Prompt`]) asks
16/// before initializing at an interactive terminal; `always`/`never` decide
17/// without a prompt.
18///
19/// [`Prompt`]: SubmoduleInit::Prompt
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub enum SubmoduleInit {
22    /// Ask before initializing (the default): at an interactive terminal, prompt
23    /// `[Y/n]` (defaulting to yes) when uninitialized submodules are present;
24    /// non-interactively, leave them alone.
25    #[default]
26    Prompt,
27    /// Never initialize submodules automatically.
28    Never,
29    /// Always run `git submodule update --init --recursive` when uninitialized
30    /// submodules are present.
31    Always,
32}
33
34impl SubmoduleInit {
35    /// Parses a `submodules.init` value (`prompt`, `never`, `always`).
36    pub fn parse(value: &str) -> Option<SubmoduleInit> {
37        match value {
38            "prompt" => Some(SubmoduleInit::Prompt),
39            "never" => Some(SubmoduleInit::Never),
40            "always" => Some(SubmoduleInit::Always),
41            _ => None,
42        }
43    }
44}
45
46/// The fully-resolved configuration after merging all layers.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Config {
49    /// Worktree-store path template (spec §6).
50    pub path_template: String,
51    /// Base ref for `new` when a branch is created; `None` resolves the repo's
52    /// default branch at runtime.
53    pub default_base: Option<String>,
54    /// Glob patterns to copy into new worktrees (spec §8).
55    pub copy: Vec<String>,
56    /// Shell command run after worktree creation.
57    pub hooks_post_create: Option<String>,
58    /// Shell command run before worktree removal.
59    pub hooks_pre_remove: Option<String>,
60    /// Editor command; `None` falls back to `$VISUAL`/`$EDITOR`.
61    pub editor: Option<String>,
62    /// Delete a wt-created branch on `remove` if fully merged.
63    pub remove_delete_merged_branch: bool,
64    /// Whether untracked files count as dirty for remove/prune guards.
65    pub remove_untracked_blocks: bool,
66    /// Remote used for PR fetches.
67    pub pr_default_remote: String,
68    /// When to auto-initialize git submodules on create/checkout (issue #50).
69    pub submodules_init: SubmoduleInit,
70    /// Default model for the AI PR auto-fill (`wt pr open --ai`); overridable
71    /// per-invocation by `--model` or the TUI's `Ctrl-M` key.
72    pub agent_model: AgentModel,
73    /// Default effort for the AI PR auto-fill; overridable by `--effort` or the
74    /// TUI's `Ctrl-E` key.
75    pub agent_effort: Effort,
76    /// Show `?` in the dirty column for untracked files.
77    pub list_show_untracked: bool,
78    /// Ordered list of columns to display in `wt list`.
79    pub list_columns: Vec<Column>,
80    /// Enable Nerd Font glyphs in the TUI.
81    pub ui_nerd_fonts: bool,
82    /// Enable mouse support in the TUI.
83    pub ui_mouse: bool,
84    /// Color output setting (reconciled with `--color`/`NO_COLOR`).
85    pub ui_color: ColorChoice,
86    /// Built-in theme preset (the base TUI palette).
87    pub ui_theme: ThemePreset,
88    /// Per-color overrides layered on top of the preset (`[ui.theme]`).
89    pub theme_overrides: ThemeOverrides,
90    /// Accumulated `ui.keybindings` overrides (applied over the defaults).
91    pub keybinding_overrides: Vec<(KeyAction, KeyChord)>,
92}
93
94impl Default for Config {
95    fn default() -> Self {
96        Config {
97            path_template: DEFAULT_TEMPLATE.to_string(),
98            default_base: None,
99            copy: Vec::new(),
100            hooks_post_create: None,
101            hooks_pre_remove: None,
102            editor: None,
103            remove_delete_merged_branch: true,
104            remove_untracked_blocks: false,
105            pr_default_remote: "origin".to_string(),
106            submodules_init: SubmoduleInit::default(),
107            agent_model: AgentModel::default(),
108            agent_effort: Effort::default(),
109            list_show_untracked: true,
110            list_columns: Column::ALL.to_vec(),
111            ui_nerd_fonts: false,
112            ui_mouse: true,
113            ui_color: ColorChoice::Auto,
114            ui_theme: ThemePreset::default(),
115            theme_overrides: ThemeOverrides::default(),
116            keybinding_overrides: Vec::new(),
117        }
118    }
119}
120
121impl Config {
122    /// Applies a parsed layer on top of this config (spec §11 merge semantics):
123    /// scalars replace, arrays (`copy`, `list.columns`) replace wholesale,
124    /// `ui.keybindings` deep-merges per action, and the `[ui.theme]` colors
125    /// deep-merge per slot (the `preset` is a scalar). Overrides accumulate in
126    /// apply order, so a later layer wins.
127    pub fn apply(&mut self, layer: ConfigLayer) {
128        if let Some(v) = layer.path_template {
129            self.path_template = v;
130        }
131        if let Some(v) = layer.default_base {
132            self.default_base = Some(v);
133        }
134        if let Some(v) = layer.copy {
135            self.copy = v;
136        }
137        if let Some(v) = layer.editor {
138            self.editor = Some(v);
139        }
140        if let Some(v) = layer.hooks_post_create {
141            self.hooks_post_create = Some(v);
142        }
143        if let Some(v) = layer.hooks_pre_remove {
144            self.hooks_pre_remove = Some(v);
145        }
146        if let Some(v) = layer.remove_delete_merged_branch {
147            self.remove_delete_merged_branch = v;
148        }
149        if let Some(v) = layer.remove_untracked_blocks {
150            self.remove_untracked_blocks = v;
151        }
152        if let Some(v) = layer.pr_default_remote {
153            self.pr_default_remote = v;
154        }
155        if let Some(v) = layer.submodules_init {
156            self.submodules_init = v;
157        }
158        if let Some(v) = layer.agent_model {
159            self.agent_model = v;
160        }
161        if let Some(v) = layer.agent_effort {
162            self.agent_effort = v;
163        }
164        if let Some(v) = layer.list_show_untracked {
165            self.list_show_untracked = v;
166        }
167        if let Some(v) = layer.list_columns {
168            self.list_columns = v;
169        }
170        if let Some(v) = layer.ui_nerd_fonts {
171            self.ui_nerd_fonts = v;
172        }
173        if let Some(v) = layer.ui_mouse {
174            self.ui_mouse = v;
175        }
176        if let Some(v) = layer.ui_color {
177            self.ui_color = v;
178        }
179        if let Some(v) = layer.ui_theme {
180            self.ui_theme = v;
181        }
182        self.theme_overrides.merge(layer.theme_overrides);
183        self.keybinding_overrides.extend(layer.ui_keybindings);
184    }
185
186    /// Resolves the effective TUI [`Palette`]: the selected preset's base palette
187    /// with any `[ui.theme]` per-color overrides applied on top.
188    pub fn palette(&self) -> Palette {
189        let mut palette = self.ui_theme.palette();
190        self.theme_overrides.apply_to(&mut palette);
191        palette
192    }
193
194    /// Builds the effective TUI keymap: the defaults with the configured
195    /// overrides applied in order.
196    pub fn keymap(&self) -> Keymap {
197        let mut keymap = Keymap::defaults();
198        for (action, chord) in &self.keybinding_overrides {
199            keymap.rebind(*action, *chord);
200        }
201        keymap
202    }
203
204    /// Resolves whether to emit color, reconciling the `--color` flag, the
205    /// `NO_COLOR` env var, and `ui.color` (spec §11 precedence).
206    pub fn color_enabled(&self, flag: Option<ColorChoice>, env: &Env, stdout_is_tty: bool) -> bool {
207        resolve_color(
208            flag,
209            env.is_set_nonempty("NO_COLOR"),
210            Some(self.ui_color),
211            stdout_is_tty,
212        )
213    }
214}
215
216/// One configuration layer (a single file's settings, or flags); every field is
217/// optional and only present keys override lower layers.
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
219pub struct ConfigLayer {
220    /// `path_template`.
221    pub path_template: Option<String>,
222    /// `default_base`.
223    pub default_base: Option<String>,
224    /// `copy`.
225    pub copy: Option<Vec<String>>,
226    /// `editor`.
227    pub editor: Option<String>,
228    /// `hooks.post_create`.
229    pub hooks_post_create: Option<String>,
230    /// `hooks.pre_remove`.
231    pub hooks_pre_remove: Option<String>,
232    /// `remove.delete_merged_branch`.
233    pub remove_delete_merged_branch: Option<bool>,
234    /// `remove.untracked_blocks`.
235    pub remove_untracked_blocks: Option<bool>,
236    /// `pr.default_remote`.
237    pub pr_default_remote: Option<String>,
238    /// `submodules.init`.
239    pub submodules_init: Option<SubmoduleInit>,
240    /// `agent.model`.
241    pub agent_model: Option<AgentModel>,
242    /// `agent.effort`.
243    pub agent_effort: Option<Effort>,
244    /// `list.show_untracked`.
245    pub list_show_untracked: Option<bool>,
246    /// `list.columns`.
247    pub list_columns: Option<Vec<Column>>,
248    /// `ui.nerd_fonts`.
249    pub ui_nerd_fonts: Option<bool>,
250    /// `ui.mouse`.
251    pub ui_mouse: Option<bool>,
252    /// `ui.color`.
253    pub ui_color: Option<ColorChoice>,
254    /// `ui.theme.preset`.
255    pub ui_theme: Option<ThemePreset>,
256    /// `[ui.theme]` per-color overrides present in this layer.
257    pub theme_overrides: ThemeOverrides,
258    /// `ui.keybindings` (action → chord) overrides.
259    pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
260}
261
262/// Per-color overrides for the TUI palette (`[ui.theme]`). Each field mirrors a
263/// [`Palette`] slot; `None` leaves the preset's color untouched.
264#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
265pub struct ThemeOverrides {
266    /// `ui.theme.accent`.
267    pub accent: Option<Color>,
268    /// `ui.theme.green`.
269    pub green: Option<Color>,
270    /// `ui.theme.red`.
271    pub red: Option<Color>,
272    /// `ui.theme.yellow`.
273    pub yellow: Option<Color>,
274    /// `ui.theme.orange`.
275    pub orange: Option<Color>,
276    /// `ui.theme.cyan`.
277    pub cyan: Option<Color>,
278    /// `ui.theme.magenta`.
279    pub magenta: Option<Color>,
280    /// `ui.theme.gray`.
281    pub gray: Option<Color>,
282    /// `ui.theme.selection_bg`.
283    pub selection_bg: Option<Color>,
284    /// `ui.theme.chip_fg`.
285    pub chip_fg: Option<Color>,
286}
287
288impl ThemeOverrides {
289    /// Merges another layer's overrides on top of these (set slots win).
290    pub fn merge(&mut self, other: ThemeOverrides) {
291        self.accent = other.accent.or(self.accent);
292        self.green = other.green.or(self.green);
293        self.red = other.red.or(self.red);
294        self.yellow = other.yellow.or(self.yellow);
295        self.orange = other.orange.or(self.orange);
296        self.cyan = other.cyan.or(self.cyan);
297        self.magenta = other.magenta.or(self.magenta);
298        self.gray = other.gray.or(self.gray);
299        self.selection_bg = other.selection_bg.or(self.selection_bg);
300        self.chip_fg = other.chip_fg.or(self.chip_fg);
301    }
302
303    /// Applies the set overrides onto a base [`Palette`].
304    fn apply_to(&self, palette: &mut Palette) {
305        if let Some(c) = self.accent {
306            palette.accent = c;
307        }
308        if let Some(c) = self.green {
309            palette.green = c;
310        }
311        if let Some(c) = self.red {
312            palette.red = c;
313        }
314        if let Some(c) = self.yellow {
315            palette.yellow = c;
316        }
317        if let Some(c) = self.orange {
318            palette.orange = c;
319        }
320        if let Some(c) = self.cyan {
321            palette.cyan = c;
322        }
323        if let Some(c) = self.magenta {
324            palette.magenta = c;
325        }
326        if let Some(c) = self.gray {
327            palette.gray = c;
328        }
329        if let Some(c) = self.selection_bg {
330            palette.selection_bg = c;
331        }
332        if let Some(c) = self.chip_fg {
333            palette.chip_fg = c;
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crossterm::event::KeyCode;
342
343    #[test]
344    fn defaults_match_spec() {
345        let c = Config::default();
346        assert_eq!(c.path_template, DEFAULT_TEMPLATE);
347        assert!(c.default_base.is_none());
348        assert!(c.copy.is_empty());
349        assert!(c.remove_delete_merged_branch);
350        assert!(!c.remove_untracked_blocks);
351        assert_eq!(c.pr_default_remote, "origin");
352        assert_eq!(c.submodules_init, SubmoduleInit::Prompt);
353        assert_eq!(c.agent_model, AgentModel::Sonnet);
354        assert_eq!(c.agent_effort, Effort::Medium);
355        assert!(c.list_show_untracked);
356        assert_eq!(c.list_columns, Column::ALL.to_vec());
357        assert!(!c.ui_nerd_fonts);
358        assert!(c.ui_mouse);
359        assert_eq!(c.ui_color, ColorChoice::Auto);
360    }
361
362    #[test]
363    fn scalars_replace_on_apply() {
364        let mut c = Config::default();
365        c.apply(ConfigLayer {
366            pr_default_remote: Some("upstream".into()),
367            ui_mouse: Some(false),
368            ..Default::default()
369        });
370        assert_eq!(c.pr_default_remote, "upstream");
371        assert!(!c.ui_mouse);
372        // Untouched fields keep their defaults.
373        assert!(c.list_show_untracked);
374    }
375
376    #[test]
377    fn arrays_replace_wholesale() {
378        let mut c = Config::default();
379        c.apply(ConfigLayer {
380            copy: Some(vec![".env".into()]),
381            list_columns: Some(vec![Column::Branch, Column::Pr]),
382            ..Default::default()
383        });
384        assert_eq!(c.copy, vec![".env".to_string()]);
385        assert_eq!(c.list_columns, vec![Column::Branch, Column::Pr]);
386        // A second layer replaces, not concatenates.
387        c.apply(ConfigLayer {
388            copy: Some(vec![".envrc".into()]),
389            ..Default::default()
390        });
391        assert_eq!(c.copy, vec![".envrc".to_string()]);
392    }
393
394    #[test]
395    fn apply_sets_every_scalar_and_optional_field() {
396        let mut c = Config::default();
397        c.apply(ConfigLayer {
398            path_template: Some("{home}/{branch_slug}".into()),
399            default_base: Some("trunk".into()),
400            editor: Some("hx".into()),
401            hooks_post_create: Some("setup".into()),
402            hooks_pre_remove: Some("teardown".into()),
403            remove_delete_merged_branch: Some(false),
404            remove_untracked_blocks: Some(true),
405            submodules_init: Some(SubmoduleInit::Always),
406            agent_model: Some(AgentModel::Haiku),
407            agent_effort: Some(Effort::Low),
408            list_show_untracked: Some(false),
409            ui_nerd_fonts: Some(true),
410            ui_color: Some(ColorChoice::Never),
411            ..Default::default()
412        });
413        assert_eq!(c.path_template, "{home}/{branch_slug}");
414        assert_eq!(c.default_base.as_deref(), Some("trunk"));
415        assert_eq!(c.editor.as_deref(), Some("hx"));
416        assert_eq!(c.hooks_post_create.as_deref(), Some("setup"));
417        assert_eq!(c.hooks_pre_remove.as_deref(), Some("teardown"));
418        assert!(!c.remove_delete_merged_branch);
419        assert!(c.remove_untracked_blocks);
420        assert_eq!(c.submodules_init, SubmoduleInit::Always);
421        assert_eq!(c.agent_model, AgentModel::Haiku);
422        assert_eq!(c.agent_effort, Effort::Low);
423        assert!(!c.list_show_untracked);
424        assert!(c.ui_nerd_fonts);
425        assert_eq!(c.ui_color, ColorChoice::Never);
426    }
427
428    #[test]
429    fn color_enabled_follows_precedence() {
430        use crate::output::color::ColorChoice;
431        let mut c = Config::default();
432        let no_env = Env::from_map(std::collections::HashMap::new());
433        // Default ui.color=auto -> follows stdout TTY.
434        assert!(c.color_enabled(None, &no_env, true));
435        assert!(!c.color_enabled(None, &no_env, false));
436        // ui.color=never overrides auto.
437        c.ui_color = ColorChoice::Never;
438        assert!(!c.color_enabled(None, &no_env, true));
439        // --color always beats config.
440        assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
441        // NO_COLOR beats config 'always'.
442        c.ui_color = ColorChoice::Always;
443        let no_color = Env::from_map(
444            [("NO_COLOR".to_string(), "1".to_string())]
445                .into_iter()
446                .collect(),
447        );
448        assert!(!c.color_enabled(None, &no_color, true));
449    }
450
451    #[test]
452    fn keybindings_deep_merge_per_action() {
453        let mut c = Config::default();
454        // Global layer rebinds navigate-up.
455        c.apply(ConfigLayer {
456            ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
457            ..Default::default()
458        });
459        // Per-repo layer rebinds navigate-up again, plus quit.
460        c.apply(ConfigLayer {
461            ui_keybindings: vec![
462                (KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('e'))),
463                (KeyAction::Quit, KeyChord::key(KeyCode::Char('x'))),
464            ],
465            ..Default::default()
466        });
467        let km = c.keymap();
468        // Per-repo wins for navigate-up.
469        assert_eq!(
470            km.action_for(KeyChord::key(KeyCode::Char('e'))),
471            Some(KeyAction::NavigateUp)
472        );
473        assert_eq!(km.action_for(KeyChord::key(KeyCode::Char('w'))), None);
474        // Quit rebound, but unrelated actions keep their defaults.
475        assert_eq!(
476            km.action_for(KeyChord::key(KeyCode::Char('x'))),
477            Some(KeyAction::Quit)
478        );
479        assert_eq!(
480            km.action_for(KeyChord::key(KeyCode::Char('n'))),
481            Some(KeyAction::New)
482        );
483    }
484
485    #[test]
486    fn theme_defaults_to_one_dark() {
487        let c = Config::default();
488        assert_eq!(c.ui_theme, ThemePreset::OneDark);
489        assert_eq!(c.theme_overrides, ThemeOverrides::default());
490        assert_eq!(c.palette(), Palette::one_dark());
491    }
492
493    #[test]
494    fn theme_preset_and_overrides_apply_and_merge() {
495        let mut c = Config::default();
496        // Global layer: solarized preset + an accent override.
497        c.apply(ConfigLayer {
498            ui_theme: Some(ThemePreset::Solarized),
499            theme_overrides: ThemeOverrides {
500                accent: Some(Color::Rgb(1, 1, 1)),
501                ..Default::default()
502            },
503            ..Default::default()
504        });
505        // Per-repo layer: override red only; preset and accent untouched.
506        c.apply(ConfigLayer {
507            theme_overrides: ThemeOverrides {
508                red: Some(Color::Rgb(2, 2, 2)),
509                ..Default::default()
510            },
511            ..Default::default()
512        });
513        assert_eq!(c.ui_theme, ThemePreset::Solarized);
514        let p = c.palette();
515        // Both overrides survive (deep-merge per slot).
516        assert_eq!(p.accent, Color::Rgb(1, 1, 1));
517        assert_eq!(p.red, Color::Rgb(2, 2, 2));
518        // A non-overridden slot keeps the solarized base.
519        assert_eq!(p.green, Palette::solarized().green);
520    }
521
522    #[test]
523    fn later_theme_override_wins_for_same_slot() {
524        let mut o = ThemeOverrides {
525            accent: Some(Color::Rgb(1, 1, 1)),
526            ..Default::default()
527        };
528        o.merge(ThemeOverrides {
529            accent: Some(Color::Rgb(9, 9, 9)),
530            ..Default::default()
531        });
532        assert_eq!(o.accent, Some(Color::Rgb(9, 9, 9)));
533    }
534}